Self-hosting (sovereign deployment)
Futuros is designed to be redeployed in-region. This is Pillar 2 (the Sovereign Model): a LATAM government or multilateral can run the whole platform — the atlas, the baked corpus, the public API, and the AI assistant — on infrastructure inside its own jurisdiction, pointing inference at an in-region open-weight gateway so the corpus and every user query stay on sovereign soil.
Because the platform is a static SPA + baked JSON corpus + a small set of serverless functions, self-hosting comes in two tiers.
Two tiers, one repo
The repo's Dockerfile + Caddyfile produce a static deployment: the SPA, the baked public/data/ corpus, the Public API v1, and the embeds. That container (Caddy serving dist/) does not run the api/ serverless functions — so the chat, MCP server, data-trust contribute/revoke, consensus, and alerts endpoints are not served by it. To run those interactive services you need a host that executes the api/* Node functions (Vercel, or any Node/functions runtime), plus the env below.
Tier 1 — static sovereign mirror (Docker + Caddy)
The included multi-stage Dockerfile builds with Bun and ships a tiny Caddy image serving the static build:
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
ARG VITE_GATE_PASSWORD # optional; unset = no SPA gate
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"]Build and run:
docker build -t futuros --build-arg VITE_GATE_PASSWORD=your-gate-pass .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080The Caddyfile listens on :{$PORT:8080}, gzips/zstd-compresses, and behaves, matcher by matcher:
| Matcher | Behaviour |
|---|---|
/assets/* | Cache-Control: public, max-age=31536000, immutable — hashed bundles |
/data/* | max-age=600, s-maxage=3600 — the baked corpus turns over within minutes of a redeploy |
/atlas/*, /geo/* | max-age=86400, immutable — static geo bundles |
/embed/* | removes X-Frame-Options, sets Content-Security-Policy: frame-ancestors * — cross-origin embeds work from a mirror |
| everything | X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy denying camera/mic/geolocation, and the Server header stripped |
Resolution order is try_files {path} {path}/ /spa.html: a real file always wins over the SPA fallback. That is why the baked /api/v1/* JSON artifacts are served even on Tier 1 (they are plain files in dist/), while a dynamic path like /api/chat has no handler there and falls through to the SPA shell. This mirrors vercel.json's rewrite of every extensionless, non-reserved path to /spa.html, with one deliberate difference: Vercel's pattern also excludes /api/*, because on Vercel the functions live there.
The fallback target matters: postbuild renames dist/index.html→spa.html and dist/landing.html→index.html (so /index.html is the landing page, not the app shell), then prerenders the landing (scripts/prerender-landing.mjs) and deletes any CLAUDE.md that leaked into dist/. railway.json wires the same Dockerfile for Railway: builder DOCKERFILE, health check /intro (30 s timeout), restart policy ON_FAILURE with 3 retries.
This tier is a complete, offline-capable data-sovereignty mirror: the full atlas UI and the entire cited corpus, hosted wherever you run the container. Anything that needs a server (below) degrades honestly — the app stays usable, the interactive features announce they are not configured rather than faking a result.
Tier 2 — full platform (serverless functions + sovereign inference)
The interactive surfaces live in api/* as nine Node serverless functions (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, o — intake receives visitor pilot proposals, introduction requests, and inconsistency reports, persisting to an Upstash queue with a Mailgun notify, honest-when-unconfigured like the rest). Deploy them on a runtime that executes them (Vercel is the reference target) and set the env vars below. The static assets and corpus are the same build as Tier 1.
Environment variables
Grouped by feature. Everything is optional — each unset key degrades a specific feature honestly (see the table further down). Client keys are VITE_-prefixed and baked into the build; the rest are server-side only.
Note: the repo's
env.exampledocuments only the map, analytics, chat/ retrieval, and trust-ledger salt keys. The Upstash, alerts (Mailgun / webhooks), and Perplexity keys below are read by the code but are not inenv.example— the authoritative list is here.
Build / client (baked, VITE_-prefixed):
| Var | Feature | Without it |
|---|---|---|
VITE_MAPBOX_TOKEN | Mapbox basemap on the atlas | MapLibre fallback renders boundaries on a dark canvas |
VITE_GATE_PASSWORD | Soft gate on preview routes (not /atlas, /comparar, /datos-abiertos, /metodologia) | Ungated — no in-source fallback password |
VITE_POSTHOG_HOST | PostHog region override | Defaults to US Cloud |
Analytics on a self-host: the PostHog project key is hardcoded in
src/lib/analytics.ts(aVITE_POSTHOG_KEYenv var is deliberately ignored), and initialization is gated tofuturos.xyz/*.futuros.xyzhostnames — a sovereign mirror on another domain emits no analytics regardless of env. The docs site (docs/.vitepress/posthog-snippet.ts) uses the same hardcoded key and hostname gate. Pointing analytics at your own PostHog project means editing those files, not setting a variable.
The gates that fail closed
Before any provider key, three explicit switches govern whether a dynamic endpoint exists at all. All fail closed: with the variable unset, the surface returns a clean refusal rather than staying open. An empty .env is, by design, a correct posture — never a leak.
| Var | What it opens | Without it |
|---|---|---|
CHAT_ENABLED | Public POST /api/chat (also needs a provider key and the Upstash rate limit) | 403 — the assistant does not answer. This is the most-forgotten step when self-hosting: with ANTHROPIC_API_KEY configured but this switch unset, chat still returns 403 |
MCP_ENABLED | Public POST /api/mcp (also needs the Upstash rate limit) | 403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 still answers and reports it in enabled |
SCRAPE_WRITE_ENABLED | The scrape write path | Observe-only mode: writes are rejected |
Each switch must be literally the string "true".
These three live alongside the rest in server/http/env-levers.ts, the canonical list of 15 production levers (each with its id, the variables that compose it, what it opens, and a public probe to verify it). That list is served at GET /api/env-levers and published on /confianza, so an operator — or a journalist — can audit a deployment's posture without access to its environment. leverStatus() reports only a set boolean per lever: never the secret's value.
AI assistant — inference provider (server-side; first match wins):
| Var(s) | Feature |
|---|---|
SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY | Pillar 2 sovereign path. Points the same Anthropic-Messages agent loop at an in-region / self-hosted open-weight gateway (LiteLLM, or vLLM/TGI behind a Messages-compatible shim, serving Llama/Qwen/DeepSeek/Mistral). When both are set they win over OpenRouter/Anthropic and the chat shows a "sovereign mode" badge — the query + corpus never leave the host. |
SOVEREIGN_INFERENCE_MODEL | Your gateway's served-model-name (default local-model) |
SOVEREIGN_INFERENCE_LABEL | UI label for the sovereign provider (default Soberano) |
SOVEREIGN_INFERENCE_REGION | Declares the gateway's region; the only way the badge asserts the stronger "in-region" residency claim |
OPENROUTER_API_KEY | Managed fallback: routes via OpenRouter's Anthropic-native endpoint (default model anthropic/claude-sonnet-4.6) |
ANTHROPIC_API_KEY | Calls api.anthropic.com directly (default claude-sonnet-4-6) |
CHAT_MODEL / CHAT_FOLLOWUP_MODEL | Override answer / follow-up model ids for the active provider |
Providers form an ordered failover chain: sovereign → OpenRouter → Anthropic. The first configured provider is primary; every additional key becomes a transparent fallback the handler switches to mid-request if the primary fails (model ids are re-resolved per provider; CHAT_MODEL / CHAT_FOLLOWUP_MODEL bind to the primary only). One key works fine; a second buys redundancy. With none set, the chat emits a not_configured event and declines rather than erroring. SOVEREIGN_INFERENCE_REGION (optional) declares the gateway's geographic region — it is the only way the chat asserts the stronger "in-region" claim behind the sovereign badge; residency is never inferred from an endpoint's mere presence.
AI assistant — retrieval & external web (server-side):
| Var(s) | Feature | Without it |
|---|---|---|
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL) | Semantic search_corpus over the baked index + cross-encoder rerank | Degrades to keyword/BM25 search (no hard failure) |
EXA_API_KEY | Chat's last-resort web fallback (web_search/fetch_url) + pulse ingestion | Chat answers from the corpus only and declines out-of-corpus questions |
PERPLEXITY_API_KEY (+ PERPLEXITY_MODEL) | perplexity_search live web synthesis (chat + MCP) | Tool returns a "not configured" note; answers stay corpus-only |
Data Trust (Pillar 1) & consensus — Upstash Redis (server-side):
| Var(s) | Feature | Without it |
|---|---|---|
UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | Append-only trust ledger (/contribuir receipts, /api/revoke, /api/intake), consensus vote store (/api/consensus), and cross-instance API rate limiting | Returns a valid receipt with persisted:false; consensus degrades to local-only; rate limits fall back to per-instance memory — never fakes persistence |
TRUST_ID_SALT | Secret salt for the pseudonymous contributor id on ledger receipts (contributor_id = sha256(salt:email); the raw email is never stored) | Falls back to the public, in-repo default salt: anyone can recompute the hash and confirm whether a given email matches a ledger record. Set a high-entropy secret in production |
TRUST_ID_SALT_PREVIOUS | Salt rotation: move the old value here (comma-separated if several); revocation matches each old pledge to its original salt via the public salt_version label, so rotating never breaks revocability | Rotating TRUST_ID_SALT without retaining the old salt leaves pledges written under it unrevocable |
The Vercel KV aliases KV_REST_API_URL / KV_REST_API_TOKEN are accepted by the consensus store and the rate limiter only — the Data-Trust ledger reads exclusively UPSTASH_REDIS_REST_URL/_TOKEN. A deployment configured with just the KV_* aliases gets working consensus but persisted:false trust receipts; set the native names for full coverage.
Alerts & email (server-side):
| Var(s) | Feature | Without it |
|---|---|---|
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROM | Email delivery for /api/alerts-subscribe confirmations and operator notices from /api/contribute and /api/intake (revoke sends no email), via Mailgun over fetch | alerts-subscribe returns a mailto: fallback instead of pretending to subscribe |
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAIL | Internal copy of subscribe/contribution/intake notices | No internal copy |
ALERTS_WEBHOOKS | The weekly digest cron (/api/alerts-digest, Mon 13:00 UTC) dispatches the global anomaly digest to these comma-separated webhook URLs | Digest dispatches to nobody |
CRON_SECRET | Authenticates the cron trigger to POST /api/alerts-digest (Vercel sends Bearer $CRON_SECRET) | Fail-closed: every POST returns 401 — the fan-out is disabled, not unguarded. (GET /api/alerts-digest?lang=es|en|pt remains a public, localized digest preview either way.) |
The weekly alerts digest is webhook-based (
ALERTS_WEBHOOKS), not email — Mailgun handles the subscribe confirmation and Data-Trust notices. Both exist; don't conflate them.
Outreach hop
| Var | Feature | Without it |
|---|---|---|
OUTREACH_REDIRECTS | Server-only JSON { "<token>": "https://…" } for GET /o/{token} → 302. Token is opaque (UUID or similar). Destination allowlist, https only: futuros.xyz, docs.futuros.xyz, v2.futuros.xyz. The hop identifies in PostHog with the token as distinct_id (identify_reason=hop_mint) and emits hop_redirect. Sets the first-party futuros_o cookie so a later product visit re-identifies. No mint API. | Every token returns 404 — no open redirect, no map enumeration |
OUTREACH_ROSTER | Server-only JSON { "<token>": { name?, institution?, email?, campaign?, internal? } }. Writes person properties on the hop; distinct_id stays the token. internal: true marks testers for PostHog's test-account filter. Lives with Data, not in the repo. | The hop identifies with outreach_token only — no name, email, or institution |
Exact shape (one object, not an array; opaque keys; https allowlisted values):
{
"550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
"7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}On Vercel: Production env OUTREACH_REDIRECTS = that JSON on one line; OUTREACH_ROSTER is the same pattern (a second object). Data adds tokens without a code change. An unknown, malformed, or disallowed-destination token is the same 404 (Not found) — the response does not reveal whether other tokens exist. Identify runs on the futuros.xyz hop before the 302 (token as distinct_id; optional roster as properties). The docs site inits the same PostHog project (VitePress snippet) and can re-persist the token; the hop does not depend on that. Do not set VITE_POSTHOG_KEY. The Tier 1 Caddy mirror does not run /o/{token} (it falls through to the SPA shell); the hop needs the api/o function.
Ingestion (build-time scripts only — not needed to serve the app): the refresh pipeline reads keys such as OPENAQ_API_KEY, CLOUDFLARE_RADAR_TOKEN, YOUTUBE_API_KEY, REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET, SERPAPI_KEY, plus the EXA_/PERPLEXITY_/GDELT_ tuning vars. These matter only when you re-run ingestion (below), never to serve an already-baked corpus.
The corpus is static, baked at build time
There is no database in the serving path. The entire corpus under public/data/ is baked at build time and shipped as static files (the same files the Public API v1 exposes). Serving Futuros means serving those files — which is why Tier 1 (Caddy over dist/) is a complete data mirror.
To refresh the data you re-run the ingestion/bake scripts and rebuild:
bun run ingest:all # multi-source ingest → parameter-cache + citations + signals
bun run bake # derived narratives etc.; then the specific bakes you need:
bun run bake:scores # composite index → scores.json
bun run bake:metrics # metric registry
bun run bake:signals # cited signals
bun run bake:api # public API v1 artifacts
bun run build # tsc + vite; prebuild re-runs bake:health/bake:ledger/bake:api
# plus the provenance gates — any failure stops the build(There is no aggregate bake:all; baking is deliberately split so a refresh can touch one family without re-deriving everything.)
prebuild is the honesty gate, in order (from package.json): typecheck of the script layer, the full bun test suite, then the check scripts — 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 — then three re-bakes (bake-data-health, bake-source-ledger, bake-api) sealed by check-api. Every step except the --warn search-index pass fails the build — a figure that lost its source cannot ship. So a self-hosted rebuild preserves the honesty contract by construction, and a sovereign operator can run the whole pipeline in-region: baked corpus in-region, inference in-region, nothing leaves the host.
Recommended sovereign setup
- Stand up an in-region open-weight gateway (LiteLLM in front of vLLM/TGI serving Llama/Qwen/DeepSeek/Mistral) that speaks the Anthropic Messages API.
- Deploy the SPA + baked corpus (Tier 1 container) and the
api/*functions on in-region infrastructure. - Set
SOVEREIGN_INFERENCE_URL+SOVEREIGN_INFERENCE_KEY(+_MODEL,_LABEL). The chat badges "sovereign mode"; corpus + queries never leave your jurisdiction. - Add
UPSTASH_REDIS_REST_*only if you want the trust ledger / consensus persisted; otherwise those features degrade honestly.