Skip to content

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:

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                 # 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:

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

The Caddyfile listens on :{$PORT:8080}, gzips/zstd-compresses, and behaves, matcher by matcher:

MatcherBehaviour
/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
everythingX-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.htmlspa.html and dist/landing.htmlindex.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, ointake 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.example documents 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 in env.example — the authoritative list is here.

Build / client (baked, VITE_-prefixed):

VarFeatureWithout it
VITE_MAPBOX_TOKENMapbox basemap on the atlasMapLibre fallback renders boundaries on a dark canvas
VITE_GATE_PASSWORDSoft gate on preview routes (not /atlas, /comparar, /datos-abiertos, /metodologia)Ungated — no in-source fallback password
VITE_POSTHOG_HOSTPostHog region overrideDefaults to US Cloud

Analytics on a self-host: the PostHog project key is hardcoded in src/lib/analytics.ts (a VITE_POSTHOG_KEY env var is deliberately ignored), and initialization is gated to futuros.xyz / *.futuros.xyz hostnames — 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.

VarWhat it opensWithout it
CHAT_ENABLEDPublic 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_ENABLEDPublic 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_ENABLEDThe scrape write pathObserve-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_KEYPillar 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_MODELYour gateway's served-model-name (default local-model)
SOVEREIGN_INFERENCE_LABELUI label for the sovereign provider (default Soberano)
SOVEREIGN_INFERENCE_REGIONDeclares the gateway's region; the only way the badge asserts the stronger "in-region" residency claim
OPENROUTER_API_KEYManaged fallback: routes via OpenRouter's Anthropic-native endpoint (default model anthropic/claude-sonnet-4.6)
ANTHROPIC_API_KEYCalls api.anthropic.com directly (default claude-sonnet-4-6)
CHAT_MODEL / CHAT_FOLLOWUP_MODELOverride 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)FeatureWithout it
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL)Semantic search_corpus over the baked index + cross-encoder rerankDegrades to keyword/BM25 search (no hard failure)
EXA_API_KEYChat's last-resort web fallback (web_search/fetch_url) + pulse ingestionChat 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)FeatureWithout it
UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENAppend-only trust ledger (/contribuir receipts, /api/revoke, /api/intake), consensus vote store (/api/consensus), and cross-instance API rate limitingReturns a valid receipt with persisted:false; consensus degrades to local-only; rate limits fall back to per-instance memory — never fakes persistence
TRUST_ID_SALTSecret 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_PREVIOUSSalt 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 revocabilityRotating 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)FeatureWithout it
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROMEmail delivery for /api/alerts-subscribe confirmations and operator notices from /api/contribute and /api/intake (revoke sends no email), via Mailgun over fetchalerts-subscribe returns a mailto: fallback instead of pretending to subscribe
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAILInternal copy of subscribe/contribution/intake noticesNo internal copy
ALERTS_WEBHOOKSThe weekly digest cron (/api/alerts-digest, Mon 13:00 UTC) dispatches the global anomaly digest to these comma-separated webhook URLsDigest dispatches to nobody
CRON_SECRETAuthenticates 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

VarFeatureWithout it
OUTREACH_REDIRECTSServer-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_ROSTERServer-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):

json
{
  "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:

bash
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.

  1. 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.
  2. Deploy the SPA + baked corpus (Tier 1 container) and the api/* functions on in-region infrastructure.
  3. Set SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY (+ _MODEL, _LABEL). The chat badges "sovereign mode"; corpus + queries never leave your jurisdiction.
  4. Add UPSTASH_REDIS_REST_* only if you want the trust ledger / consensus persisted; otherwise those features degrade honestly.

Every figure with its source — traceability is the contract.