ADR-0028 — Browser API base is relative; proxy target is a server-only env var¶
- Status: Accepted
- Date: 2026-06-29
- Sprint: v0.5 (M5 — live-UI review bugfix; Bug 1 critical/latent + Bug 2)
- Feature: F1 (3-panel shell / web service plumbing) · F15 (cross-platform: Tailscale + Cloudflare Tunnel reachability) · F16 (multi-provider chat over a tunnel — the only client that worked there today)
- Builds on: the per-client
VITE_API_BASEconvention established across v0.3–v0.5 (frontend/src/api/*.ts) and the Vite dev proxy infrontend/vite.config.ts. - Invariants owned: I5 (Obsidian compat — unaffected, but the change MUST stay pure transport plumbing) · I9 (do not reinvent — no new client/proxy framework; reuse the existing Vite proxy). No invariant is traded for convenience.
- Author: solution-architect
- Implementers: frontend-engineer (
frontend/src/api/*.ts,frontend/vite.config.ts,frontend/.env.example) · devops-engineer (docker-compose.dev.yml)
1. Context¶
A live UI review surfaced two related defects rooted in one variable carrying two incompatible meanings.
VITE_API_BASE is read in two layers that run in two different places:
- Browser bundle —
import.meta.env["VITE_API_BASE"]in eachfrontend/src/api/*.tsclient. Vite inlines this value into the JS shipped to the browser at build time. The browser, not the container, performs the fetch. - Vite dev server (Node) —
process.env["VITE_API_BASE"]invite.config.ts, used as the server-side proxytarget. This runs inside the frontend container.
These two consumers need opposite values:
- The browser must reach the backend over whatever origin the user is on — host
localhost, a Tailscale MagicDNS name, or a Cloudflare Tunnel hostname. The only value that is correct for all three is relative (""): same-origin, let the page's own host/scheme decide. - The dev proxy must reach the backend over the Docker network, i.e. the service name
http://synapse-backend:8000, which is resolvable only inside the compose network.
docker-compose.dev.yml:65 sets VITE_API_BASE: http://synapse-backend:8000 to satisfy
consumer (2). But because Vite inlines it for consumer (1), the browser is told to fetch a
Docker-internal hostname it cannot resolve. It appears to work only by accident: the
developer's ISP NXDOMAIN-redirects unknown names to 127.0.0.1, which happens to be the
host where the backend port is published. On Tailscale or Cloudflare Tunnel — the actual
deployment targets (F15) — synapse-backend does not resolve and every API call fails.
This is Bug 1: critical and latent (silent on the dev box, broken everywhere it matters).
Compounding it (Bug 2): 8 of 9 clients default VITE_API_BASE to
http://localhost:8000; only chatClient.ts defaults to "". So over a tunnel chat is
the only feature that works — everything else points the browser at the user's own
localhost. The inconsistency masked Bug 1 differently per client.
2. Decision¶
Split the one overloaded variable into two, by execution context, and make the browser default relative.
2.1 Browser fetch base — relative "" is the default¶
Every client in frontend/src/api/*.ts reads import.meta.env["VITE_API_BASE"] and
defaults to "" (matching the already-correct chatClient.ts):
const API_BASE = (import.meta.env["VITE_API_BASE"] as string | undefined) ?? "";
""⇒ all requests are same-origin relative paths (/graph,/pages,/chat, …). Correct on hostlocalhost, Tailscale, and Cloudflare Tunnel, because the browser uses the page's own origin. In prod the same origin serves both the static bundle and the API.VITE_API_BASEMAY still be set to an absolute URL for unusual split-origin deployments (static host ≠ API host). That is an opt-in escape hatch, not the default, and must never be set to a Docker-internal name (that name has no meaning in a browser).
This makes all 9 clients identical and removes the 8-vs-1 inconsistency (Bug 2).
2.2 Proxy target — a new server-only env var¶
The dev proxy target moves to a new variable that is NOT prefixed VITE_, so Vite
never inlines it into the browser bundle (the VITE_ prefix is exactly Vite's "expose
to client" marker). Definitive name and default:
| Variable | Read by | Where it runs | Default | Inlined into browser? |
|---|---|---|---|---|
VITE_API_BASE |
frontend/src/api/*.ts (import.meta.env) |
Browser | "" (relative) |
Yes (by design) — but unset by default |
BACKEND_PROXY_TARGET |
frontend/vite.config.ts proxy target (process.env) |
Vite dev server (Node, in container) | http://localhost:8000 |
No (no VITE_ prefix) |
vite.config.ts proxy entries read process.env["BACKEND_PROXY_TARGET"] ?? "http://localhost:8000"
and must stop reading VITE_API_BASE for the proxy target. (The 14 existing proxy
entries — /graph, /pages, /status, /ingest, /provider, /conversations, /chat,
/import-schedule, /config, /mcp, /research, /review, /search — change only the
target expression; route list and changeOrigin: true are unchanged.)
2.3 docker-compose.dev.yml frontend service¶
Replace the misused var. The browser base stays relative because VITE_API_BASE is not
set; the proxy reaches the backend over the Docker network:
environment:
BACKEND_PROXY_TARGET: http://synapse-backend:8000
# Do NOT set VITE_API_BASE — the browser base must stay relative ("").
Flow in dev: browser → relative /graph → Vite dev server (origin the browser is already
on: localhost:5173, the Tailscale host, or the tunnel) → proxy → synapse-backend:8000
over the Docker network. The Docker-internal hostname now lives only where it resolves.
2.4 .env.example¶
Document both variables and the new defaults, so the contract is discoverable:
# Browser API base. Leave EMPTY for same-origin (correct for localhost, Tailscale,
# Cloudflare Tunnel). Set to an absolute URL ONLY for split-origin deployments.
# NEVER set this to a Docker-internal hostname — it is inlined into the browser bundle.
VITE_API_BASE=
# Dev-only, server-side Vite proxy target (NOT exposed to the browser).
# Default http://localhost:8000; compose overrides to http://synapse-backend:8000.
BACKEND_PROXY_TARGET=http://localhost:8000
2.5 Docs (I8)¶
- No D2/D4 change (no schema, no API surface change).
- D6b (
docs/DEPLOY.md) should note the two-variable contract when the deploy guide is completed in v0.6; flagged, not blocking for this bugfix.
3. Consequences¶
Positive
- Bug 1 fixed at the root: the browser never receives a Docker-internal hostname. Synapse
becomes reachable over Tailscale and Cloudflare Tunnel (F15), not just on the dev box's
accidental ISP redirect.
- Bug 2 fixed: all 9 clients share one identical, correct default (""). No
feature-by-feature divergence.
- One variable, one meaning, one execution context each. The VITE_ prefix now correctly
signals "browser-exposed", and the absence of it on BACKEND_PROXY_TARGET correctly keeps
the Docker hostname server-side.
- Same-origin by default also sidesteps CORS entirely in the common deployment.
Trade-offs / limitations (stated explicitly)
- Split-origin deployments (static bundle and API on different hosts) now require an
explicit opt-in by setting VITE_API_BASE to an absolute URL. This is intentional:
the safe, tunnel-correct behaviour is the default; the unusual case is the one that must
be configured.
- A developer running the Vite dev server outside Docker (bare npm run dev) must have
the backend on localhost:8000 (the BACKEND_PROXY_TARGET default) or set the var. This
matches today's bare-metal expectation; no regression.
- VITE_API_BASE is build-time inlined: changing it requires a rebuild, not just a restart.
Unchanged from today; the default-empty value means most users never touch it.
Invariant check
- I5 (Obsidian): unaffected — this is transport plumbing only; no change to vault
files, frontmatter, or wikilinks. The implementers MUST keep it that way.
- I9 (do not reinvent): reuses the existing Vite proxy and import.meta.env mechanism;
introduces no new client, gateway, or proxy framework. One renamed/added env var.
- No other invariant (I1–I4, I6–I8) is touched. No invariant is traded for convenience.
4. Contract the implementers follow verbatim¶
- Every
frontend/src/api/*.tsclient defaultsVITE_API_BASEto""(nothttp://localhost:8000). After the change,grep -r 'http://localhost:8000' frontend/src/apireturns nothing. frontend/vite.config.tsproxytargetreadsprocess.env["BACKEND_PROXY_TARGET"] ?? "http://localhost:8000"for all proxy entries, and no longer referencesVITE_API_BASE.docker-compose.dev.ymlfrontend service setsBACKEND_PROXY_TARGET: http://synapse-backend:8000and does NOT setVITE_API_BASE.frontend/.env.exampledocuments both vars per §2.4 (VITE_API_BASEempty default;BACKEND_PROXY_TARGET=http://localhost:8000).
5. Do NOT (reject any PR that does these)¶
- Do NOT set
VITE_API_BASEto a Docker-internal hostname (synapse-backend) anywhere — it is inlined into the browser bundle, which cannot resolve it. - Do NOT keep
http://localhost:8000as a client default in anyapi/*.tsfile. - Do NOT read
VITE_API_BASEinvite.config.tsfor the proxy target — useBACKEND_PROXY_TARGET. - Do NOT prefix the proxy-target var with
VITE_(that would re-expose the server-only value to the browser and recreate Bug 1).