The edge: nginx + Tailscale Funnel¶
A Polaris host usually runs several services on several ports — the SPA, the capability gateway, the
Advisor, the docs site. This page documents the single public entrypoint that fronts them: an
nginx reverse proxy behind a Tailscale Funnel, which is how the reference deployment
(polaris-themis-lime) is reachable from outside the host.
┌──────────────── Tailscale Funnel (terminates TLS) ────────────────┐
internet ──▶│ https://<host>.ts.net ──plaintext HTTP──▶ nginx :80 │
└───────────────────────────────┬──────────────────────────────────┘
│ one location block per app
┌───────────────────┬───────────────┼────────────────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼
/ (static) /docs/ :8765 /polaris/ :5174 /polaris-api/ :8088 /v1/ :8088
landing page MkDocs site Brain Builder SPA SPA → gateway customer API
Funnel is the public internet
tailscale funnel publishes to anyone with the URL — it is not tailnet-only. Use
tailscale serve instead if you want a path reachable only from your own devices. Everything
routed below is either static, or authenticated (see What's safe to expose).
Routes¶
| Path | Upstream | What it serves |
|---|---|---|
/ |
nginx static (httpdocs/) |
Landing page linking the apps below |
/docs/ |
127.0.0.1:8765 |
This documentation site (Serving the docs) |
/server/ |
127.0.0.1:8081 |
Vite/React status dashboard (host health, DNS) |
/polaris/ |
127.0.0.1:5174 |
Brain Builder SPA — coupled to provisioning flags, see below |
/polaris-api/ |
127.0.0.1:8088 → / |
Capability gateway for the SPA: REST + WS chat + SSE (prefix stripped) |
/v1/ |
127.0.0.1:8088 |
Customer API — Advisor MCP + OpenAI-compatible completions |
The two /polaris* blocks are coupled to how the workspace was provisioned — the SPA bakes its
asset base and API origin in at build time:
./scripts/provision-workspace.sh \
--seed env/workspaces/themis-lime/themis-lime.workspace.yml \
--public-origin https://<host> \
--base /polaris/ --api-prefix /polaris-api
Change a path in nginx → change the matching flag here (and re-provision), or the SPA loads but its assets and API calls 404.
The customer API at /v1/¶
The Advisor's two public surfaces are both mounted on the gateway and both live under /v1, so a
single location block exposes them at exactly the URLs the reference docs publish:
| Public URL | Surface |
|---|---|
https://<host>/v1/mcp |
streamable-http MCP — Advisor MCP endpoint |
https://<host>/v1/brains/{brain_id}/chat/completions |
OpenAI-compatible completions |
That's why /v1/ is proxied at the root rather than under /polaris-api/ — an OpenAI SDK's
base_url is then just https://<host>/v1/brains/<brain_id>, with no prefix to explain.
Two details that matter for these routes:
- No prefix strip. The gateway's own routes are already
/v1/…, so neitherlocationnorproxy_passtakes a trailing slash (unlike/polaris-api/, which strips its prefix). - Unbuffered, long-lived. MCP streamable-http and
"stream": truecompletions are both SSE, so the block setsproxy_buffering offand a 3600s read/send timeout. Without this, responses arrive in one lump at the end, and long sessions get cut.
What's safe to expose¶
Publishing /v1/ on a public Funnel is deliberate, and rests on the auth model described in
Auth — API keys:
- Every
/v1route requires a bearer credential; unauthenticated requests get 401. - An API key carries only
advisor:read— presented to a management route it gets 403insufficient_scope, so a leaked key cannot commit, certify, or delete. - Keys are bound to one brain and revocable, effective on the next request.
The operator (stdio) MCP server is a different surface — full write tools, unauthenticated, local by design. It is not routed here, and must never be. See Operator (stdio) server.
Serving the docs¶
/docs/ proxies to a plain static server on port 8765 holding the built site:
./scripts/build-docs.sh # → ./site
./scripts/serve-docs.sh --static -p 8765 # serve ./site with no-cache headers
Run it detached so it outlives your shell:
setsid nohup uv run --no-project python scripts/servedocs.py \
-d "$(pwd)/site" -p 8765 > /tmp/polaris_docs.log 2>&1 < /dev/null &
Refreshing published content is just ./scripts/build-docs.sh — the server reads ./site off disk
on every request, so there's nothing to restart. Material emits relative asset and nav links, so
the site works unchanged under the /docs/ prefix; only site_url (canonical tag + sitemap.xml)
is absolute.
Operating notes¶
A single-file bind mount does not pick up edits. The nginx config is mounted file-by-file
(./default.conf:/etc/nginx/conf.d/default.conf:ro), so an editor that writes via rename swaps the
inode and the container keeps serving the old file — nginx -s reload silently changes nothing.
After editing, recreate the container:
cd <nginx-dir> && docker compose up -d --force-recreate
docker exec gateway-nginx nginx -t # confirm the config that's actually loaded
Directory mounts (../httpdocs) don't have this problem — edits there are live.
Redirects must not downgrade the scheme. Funnel terminates TLS and forwards plaintext HTTP, so
inside nginx $scheme is http. An absolute redirect would hand the client an http:// URL for an
https-only hostname; the /v1/mcp → /v1/mcp/ redirect therefore sets absolute_redirect off to
emit a relative Location. The nginx container runs with network_mode: host, so upstream services
see 127.0.0.1 as the client and trust the X-Forwarded-Proto header the blocks set.
Verifying¶
H=https://<host>
curl -sL -o /dev/null -w '%{http_code}\n' $H/docs/ # 200 — docs
curl -sL -o /dev/null -w '%{http_code}\n' $H/polaris/ # 200 — SPA
curl -s -o /dev/null -w '%{http_code}\n' $H/polaris-api/health # 200 — gateway
# customer API: 401 without a key, 200 with one, 403 on a management route
curl -s -o /dev/null -w '%{http_code}\n' -X POST $H/v1/mcp/ -d '{}'
curl -s -o /dev/null -w '%{http_code}\n' $H/v1/brains/<brain_id>/api-keys \
-H "Authorization: Bearer $POLARIS_API_KEY"
An end-to-end MCP check with a real key:
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
import asyncio, os
t = StreamableHttpTransport(url="https://<host>/v1/mcp/",
headers={"Authorization": f"Bearer {os.environ['POLARIS_API_KEY']}"})
async def main():
async with Client(t) as c:
print([x.name for x in await c.list_tools()]) # answer, retrieve, read_wiki
asyncio.run(main())