Skip to content

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 APIAdvisor 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 neither location nor proxy_pass takes a trailing slash (unlike /polaris-api/, which strips its prefix).
  • Unbuffered, long-lived. MCP streamable-http and "stream": true completions are both SSE, so the block sets proxy_buffering off and 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 /v1 route requires a bearer credential; unauthenticated requests get 401.
  • An API key carries only advisor:read — presented to a management route it gets 403 insufficient_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())