The Public Path Table
HybridInference ships two long-running HTTP services: the FastAPI gateway
(apps/backend) and a Next.js console (apps/frontend). Only one of them needs
to be reachable from the internet.
The console owns the public path table. It is the process that terminates
public traffic. Static gateway forwarding lives in the rewrites()
configuration in apps/frontend/next.config.js; proxies whose destinations
must remain changeable after the image is built live in App Router route
handlers. Anything not named by either mechanism is served by the console’s
own pages.
So exposing the console’s port exposes every gateway path in the table below,
and publishing a new path means editing next.config.js or adding a route
handler — not changing the reverse proxy or tunnel in front. A path missing
from both gets the console’s HTML 404 page, which an API client will read as
“the gateway is down” rather than “this path is not forwarded”.
client ──▶ (your edge: CDN / tunnel / reverse proxy)
│
▼
Next.js console ──┬──▶ FastAPI gateway (build-time rewrites)
├──▶ cloud agent (runtime route handler)
├──▶ pgAdmin (runtime route handler, admin-gated)
└──▶ its own pages (everything else)
The path table
The table below combines the static rewrites with the filesystem route
handlers. apps/frontend/next.config.js is the source of truth for rewrites;
the matching route.ts file is the source of truth for a runtime handler.
Destination resolution is deliberately split. Next resolves rewrites() at
build time and writes them into .next/routes-manifest.json. Route handlers
read their server-only environment at request time, so changing those targets
requires recreating the container, not rebuilding the image.
Variable |
Default |
Resolution |
Points at |
|---|---|---|---|
|
|
build time for rewrites and server-side backend calls |
the FastAPI gateway |
|
(unset) |
runtime |
the standalone cloud agent’s web app |
|
(unset) |
runtime |
that agent’s control-plane API |
|
|
runtime |
pgAdmin |
BACKEND_INTERNAL_URL is fixed when the console image is built, not when the
container starts. The same value is compiled into both the rewrite manifest and
the server-only BUILT_BACKEND_INTERNAL_URL used by /site-config and
pgAdmin’s admin check, so a runtime override cannot quietly send only some
backend requests somewhere new. The published image uses http://backend:8080,
so a deployment that runs it must give the backend that network name. A
different backend address means rebuilding the console.
Runtime route handler — the cloud agent proxy
apps/frontend/src/app/agents/[[...path]]/route.ts reads both agent targets on
every request. With either variable unset, /agents truthfully answers 404;
an invalid target or an unreachable configured service answers 502.
For an agent served on its own hostname, set AGENT_PUBLIC_URL to its public
HTTPS URL (without credentials). The dashboard’s Agents tile uses that URL
at runtime, independently of the proxy targets; /agents remains disabled
when either proxy target is unset. With no public URL, the tile links to
/agents only when both proxy targets are configured. The tile remains
restricted to internal users. Unlike the private targets, the public URL is
included in the browser’s site configuration.
Source |
Destination |
Note |
|---|---|---|
|
|
prefix stripped — the control plane serves its routes at its own root |
|
|
prefix kept — that app is built with |
|
|
bare prefix |
Three details matter here:
/agents/api/:path*is matched before/agents/:path*. The first is a prefix of the second, so with the order reversed every API call is answered with the web app’s HTML.The handler streams request and response bodies, including SSE, but it cannot accept WebSocket upgrades. A future WebSocket endpoint needs an upgrade-aware proxy in front of Next.js.
Hop-by-hop headers are removed, cookies are preserved, and redirects that name either internal agent service are folded back through its public prefix.
Legacy beforeFiles compatibility
next.config.js still emits the same three rules as beforeFiles rewrites when
both agent URLs are supplied while building the image, as some older
distribution pipelines do. This repository’s Compose does not pass them, and
its images use the runtime handler above. Because beforeFiles wins over a
filesystem route, an image built with those values keeps its baked-in targets
and cannot be retargeted by changing only the container environment.
afterFiles — the gateway
Every destination below is ${BACKEND_INTERNAL_URL} plus the same path.
Source |
Serves |
|---|---|
|
the OpenAI-compatible API surface |
|
the Anthropic Messages surface |
|
authentication routes |
|
the user dashboard API |
|
the admin API |
|
cookie-session admin check (used by the pgAdmin handler) |
|
the admin-only model playground the console’s dashboard calls |
|
model catalog read |
|
single-user status read |
|
whether one user may use the Cloud Agent, read by the standalone agent (see Backend Extensions) |
|
mint an inference grant ( |
|
renew / usage / revoke for one grant |
|
health check |
|
the public site banner |
|
public deployment identity, consumed by the console’s |
Note on /internal: the entries are named individually rather than forwarded as
a blanket /internal/:path*, and that is deliberate. The prefix is shared —
/internal/verify-admin authenticates a browser session by cookie — so a blanket
rule would publish whatever route lands under /internal next without anyone
deciding it should be reachable from outside. /internal/agent-grants is the
one exception granted a whole sub-prefix, because every route on that router
carries a dispatch-token dependency at the router level and so is authorized by
construction.
Why /pgadmin is a route handler and not a rewrite
This section is for contributors who need to write a route handler of their own; the pgAdmin proxy is the worked example.
pgAdmin must only be reachable by an admin. A rewrite cannot authenticate —
it is a static mapping evaluated before any of your code runs, with no way to
call out, inspect a session, or refuse. So /pgadmin is not in the table at
all. It is an app route,
apps/frontend/src/app/pgadmin/[[...path]]/route.ts, which is a filesystem
route and therefore wins over afterFiles rewrites. The handler proxies to
pgAdmin itself, after checking the caller.
The handler is a compact worked example of an authenticated reverse proxy in a Next.js route handler, and every one of its decisions generalizes:
Fail closed.
verifyAdmin()callsGET /internal/verify-adminon the gateway with the caller’s cookie and a 5-second timeout. Only an explicit200admits. A backend that is down, slow, or answering something unexpected denies. It assumes it is the only thing standing in front of a database console, because it cannot tell whether pgAdmin has its own login (that depends onPGADMIN_CONFIG_SERVER_MODE, which defaults toFalse).Strip the console’s own session cookie before forwarding. pgAdmin has no use for it, and forwarding a session credential to a proxied app is how those leak.
Drop hop-by-hop headers (RFC 9110 §7.6.1) plus
hostandcontent-length, whichfetchderives from the outgoing request. Dropcontent-encodingon the way back, becausefetchalready decompressed the body. Re-splitSet-CookiewithgetSetCookie()—Headers.forEachfolds repeated values into one string.Fold upstream-absolute redirects back to bare paths. When a request arrives without a trailing slash a route requires, Werkzeug builds an absolute redirect from the
Hostit saw — here the internal container name, which resolves nowhere in a browser.foldUpstreamRedirect()rewrites those to a path, matching on hostname rather than origin so it holds whatever port is on the URL.Redirect with a bare path, not an absolute URL. Behind a tunnel the app sees its own bind address as
Host, sonew URL('/login', request.nextUrl)renders ashttps://0.0.0.0:3001/loginand strands the browser. The handler writes theLocationheader by hand, sinceNextResponse.redirect()accepts only absolute URLs.
The trailing-slash interaction
skipTrailingSlashRedirect: true is set globally in next.config.js, because
proxied applications own their path semantics. Next normally redirects a
trailing slash away; pgAdmin (Flask) or the agent web app may add one back. Left
on, the two layers can bounce a browser between them forever. A proxied path has
to reach its upstream exactly as the browser asked for it.
Skipping the redirect everywhere would change every other URL on the site, so
apps/frontend/src/middleware.ts reimplements the redirect for everything
except the /pgadmin and /agents prefixes — a 308 to the slash-less path,
built from new URL(request.url) rather than nextUrl.clone() (a cloned
NextURL remembers the incoming trailing slash and re-serializes it,
redirecting the request to exactly where it already is).
Adding a public path
Choose the routing mechanism. Add a static rule to
rewrites()inapps/frontend/next.config.js; use aroute.tshandler when its target must remain configurable after build or the request needs application logic.Name it specifically. Prefer
/prefix/thingover/prefix/:path*unless every current and future route under that prefix is authorized by construction.If the path needs a check the destination cannot make for itself, it is a route handler, not a rewrite.
Rebuild the console for a source or rewrite-table change. After that image is deployed, a runtime handler’s target can be changed by recreating its container; a static rewrite target still requires another image build.
Add the path to the table above.