Tunnel Tools
Tunnels let the agent expose a local port on the runner machine through the PizzaPi relay server as a public URL in the web UI — accessible from any browser, even when the runner is on a different machine or behind NAT.
How It Works
Section titled “How It Works”When the agent creates a tunnel, the following happens:
- The agent calls
create_tunnelwith a local port number. - A
service_messageis sent over the relay’s Socket.IO connection to the runner daemon’s TunnelService. - The daemon registers the port and confirms the tunnel.
- The relay server begins proxying HTTP requests (and WebSocket upgrades) from the public URL to
127.0.0.1:<port>on the runner.
All traffic flows through the relay server — the runner’s local port is never directly exposed to the internet. The relay handles:
- HTTP proxying —
GET,POST,PUT,DELETE,PATCH,HEAD, andOPTIONSrequests are forwarded to the local service and streamed back. - WebSocket proxying — upgrade requests are tunneled through a second WebSocket channel, enabling live-reload, HMR, and real-time protocols.
- HTML/JS/CSS rewriting — the proxy rewrites absolute paths in HTML responses, ES module imports, and CSS
url()references so they route through the tunnel prefix. An injected interceptor script patchesfetch,XMLHttpRequest,EventSource,WebSocket,history.pushState,history.replaceState,location.assign,location.replace,navigator.sendBeacon,window.open,Element.prototype.setAttribute, and dynamic resource loading at runtime.
URL Schemes
Section titled “URL Schemes”Tunnels support two URL schemes:
| Scheme | URL pattern | Stability |
|---|---|---|
| Runner-based (preferred) | /api/tunnel/runner/<runnerId>/<port>/ |
Stable across session switches |
| Session-based (legacy) | /api/tunnel/<sessionId>/<port>/ |
Breaks when session changes |
The agent automatically prefers runner-based URLs when a runner ID is available, falling back to session-based URLs otherwise.
Both patterns are gated on an interactive relay session — they only load in a
browser already logged into the relay, and return 401 anywhere else. So
create_tunnel and list_tunnels do not hand those URLs out directly: they mint
a self-authenticating URL instead, preferring the dedicated tunnel origin below
and otherwise returning a signed /api/tunnel/auth/<token>/… path. Those work in
any browser, on any device, with no cookie — the URL itself is the credential, so
treat it as a secret and expect it to expire.
Origin isolation
Section titled “Origin isolation”Tunnelled content is controlled by whatever runs on the runner’s port, so it never executes as the PizzaPi relay origin (where it could drive your signed-in UI and API):
- Path-prefix responses are sandboxed. The relay serves every
/api/tunnel/…response withContent-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads. There is noallow-same-origin, so the document gets an opaque origin, both in the web UI’s iframe and when opened in a new tab. The app’s ownContent-Security-Policyis kept alongside the sandbox. On signed paths and the dedicated tunnel origin, which the UI frames cross-origin, the relay dropsX-Frame-Optionsand removes only theframe-ancestorsdirective from the app’s CSP. Every other directive (script-src,connect-src, …) still applies. - Browsers load the signed path. In an opaque-origin document, subresource
requests carry no relay cookie. The web UI, the mobile app, and “open in new
tab” therefore mint a signed
/api/tunnel/auth/<token>/…URL (valid 24 hours). The preview’s and service panel’s Reload button mints a new URL, and a tunnel preview remints when its port is exposed again, so an expired link or one bound to an earlier exposure recovers without reopening the panel. A browser navigation to a cookie path (/api/tunnel/runner/…or/api/tunnel/<sessionId>/…) is redirected to an equivalent signed path. API clients that do not navigate (curl,fetchfrom the UI) are served directly. The signed path answers CORS for the sandbox’snullorigin, so the app’s ownfetch, module scripts, and fonts keep working. - Storage is per page load.
localStorage,sessionStorage, anddocument.cookiethrow in an opaque origin. The relay’s injected script replaces them with in-memory versions, so apps keep running, but nothing persists across reloads. - No fallback. If the UI cannot mint a URL, the panel shows an error. It does not fall back to the relay origin.
Set PIZZAPI_TUNNEL_DOMAIN (below) and previews and service panels load on
the dedicated tunnel origin instead. That origin is a separate site, so it
keeps normal same-origin privileges: persistent storage, its own cookies, and
SPA routing. The UI uses the tunnel origin only when the viewer can reach it:
no mixed content, and a *.localhost domain only when the UI itself is opened
on localhost. Otherwise it uses the sandboxed signed path.
Dedicated tunnel origin (recommended for SPAs)
Section titled “Dedicated tunnel origin (recommended for SPAs)”The path-prefix schemes above share the relay’s origin, so a tunnelled app sees
/api/tunnel/… in location.pathname. Client-side routers (React Router,
Next.js, SvelteKit) read that path and render their 404 page. The proxy’s
HTML/JS rewriting compensates for asset loading, but window.location cannot
be forged — SPA routing under a path prefix is fundamentally lossy.
Set PIZZAPI_TUNNEL_DOMAIN on the relay to give each tunnel its own origin
instead:
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 # value: [scheme://]host[:port]Each tunnel gets an opaque random subdomain — https://<label>.<domain>/ — and
the relay proxies the path verbatim: no rewriting, no injected interceptor,
no prefix. SPA routing, location.href, forms, and WebSockets all work
unmodified. Transport is unchanged: every request still flows through the relay
and the runner’s encrypted tunnel channel; the label is Redis-backed (1 hour
sliding TTL), unguessable (128-bit), and ownership is re-verified per request.
DNS options:
| Deployment | Setting | Notes |
|---|---|---|
| Same machine | t.localhost:<port> |
Browsers resolve *.localhost to loopback — zero DNS, zero certs |
| LAN / tailnet | <ip-with-dashes>.sslip.io |
e.g. 100-64-1-2.sslip.io — public wildcard DNS to any IP, no setup. Still needs TLS: the UI iframes the tunnel origin from an HTTPS page, so a plain-http domain is mixed-content blocked |
| Real domain | https://t.example.com |
Wildcard DNS record *.t.example.com + wildcard TLS (Caddy below) |
With pizza web, pass the variable once — it persists in the web config:
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 pizza webWildcard TLS with the bundled Caddy
Section titled “Wildcard TLS with the bundled Caddy”A wildcard origin needs a wildcard certificate, which neither tailscale serve
nor a plain reverse proxy can issue. pizza web can deploy one for you — set
PIZZAPI_CADDY=1 and it adds a Caddy service to the generated compose file,
writes ~/.pizzapi/web/Caddyfile, and points *.<tunnel domain> at the relay:
PIZZAPI_TUNNEL_DOMAIN=https://t.example.com:8444 PIZZAPI_CADDY=1 pizza webThe listen port comes from PIZZAPI_TUNNEL_DOMAIN (default 443), and the
upstream is the relay container — the relay only reads the Host header, so
nothing else changes. Two certificate modes:
| Mode | When | Trade-off |
|---|---|---|
| Internal CA (default) | No DNS credentials given | Zero setup, works on a private LAN/tailnet with no inbound reachability — but every device must trust Caddy’s root CA once, or tunnels break in the web UI (see below) |
| DNS-01 wildcard | PIZZAPI_CADDY_DNS_PROVIDER + PIZZAPI_CADDY_DNS_TOKEN |
Publicly trusted Let’s Encrypt cert, still no inbound :80 needed — requires a real domain and a DNS API token |
DNS-01 is the one to want: the certificate is publicly trusted, so no device ever has to install a root CA, and the challenge is solved over the DNS API — the relay itself never has to be reachable from the internet. A tailnet-only relay can hold a real Let’s Encrypt wildcard.
Either way you supply the wildcard DNS record yourself: *.t.example.com → the
relay host. <ip-with-dashes>.sslip.io covers that for free on a LAN or tailnet
with no domain at all.
Hand-rolling the proxy instead is still fine — drop a compose.override.yml next
to the generated compose file and leave PIZZAPI_CADDY unset. When
PIZZAPI_TUNNEL_DOMAIN is unset, tunnels fall back to the path-prefix schemes
above.
create_tunnel
Section titled “create_tunnel”Expose a local port through the relay and get a public URL.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
port |
number |
Yes | Local port to expose (1–65535) |
name |
string |
No | Human-readable label (e.g. "dev-server", "storybook") |
Returns: A text result containing the port, optional name, and the public URL. The structured details object includes:
{ "port": 3000, "name": "dev-server", "url": "/tunnel/3000", "publicUrl": "https://your-relay.example.com/api/tunnel/runner/abc123/3000/"}url is the internal relay path fragment for that port; publicUrl is the real externally accessible URL.
Example agent output:
Tunnel created successfully. Port: 3000 Name: dev-server Public URL: https://your-relay.example.com/api/tunnel/runner/abc123/3000/list_tunnels
Section titled “list_tunnels”List all currently active tunnels for this session.
Parameters: None.
Returns: A text summary of active tunnels with their ports, names, pinned status, and public URLs. The structured details object includes:
{ "tunnels": [ { "port": 3000, "name": "dev-server", "url": "http://127.0.0.1:3000", "publicUrl": "https://your-relay.example.com/api/tunnel/runner/abc123/3000/", "pinned": false } ]}Example agent output:
1 active tunnel(s): :3000 (dev-server) → https://your-relay.example.com/api/tunnel/runner/abc123/3000/ :4173 (preview) [pinned] → https://your-relay.example.com/api/tunnel/runner/abc123/4173/close_tunnel
Section titled “close_tunnel”Close an active tunnel and stop proxying traffic to the local port.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
port |
number |
Yes | Port of the tunnel to close (1–65535) |
Returns: Confirmation that the tunnel was closed.
{ "closed": true, "port": 3000}Authentication & Security
Section titled “Authentication & Security”The relay enforces several security measures:
- Auth-only access — every tunnel request requires a valid session cookie or API key, or a short-lived signed path token minted by the runner/session owner for mobile/cross-origin iframe access.
- Dedicated signing secret — tunnel tokens are signed with
PIZZAPI_TUNNEL_TOKEN_SECRET(distinct from the Better Auth session secret). When unset, the relay falls back toBETTER_AUTH_SECRETso unconfigured deployments keep working. Set it to allow independent rotation: rotating the tunnel secret does not invalidate existing auth sessions.- Key rotation — set
PIZZAPI_TUNNEL_TOKEN_SECRET_PREVIOUSto the old secret; tokens signed by it are still accepted until they expire. Keep it for at least the longest lifetime the relay issues before removing it: path tokens default to 1 hour, the web UI, mobile app, and browser-navigation redirects mint 24-hour tokens, andcreate_tunnel/list_tunnels(orPOST /api/tunnel-tokenwithttlHours) can request up to 168 hours (7 days). Removing the previous secret sooner immediately invalidates every still-unexpired link signed with it; those viewers must press Reload or request a new link. Tunnel-origin labels are stored in Redis, not signed, so rotation does not affect them.
- Key rotation — set
- Token claims — every token carries
aud(pizzapi:tunnel),iat(issued-at),kid(key identifier derived from the active secret), andexp. On verify:aud,kid, andexpare all enforced. A token whosekiddoes not match the active key (or the previous key, if configured) is rejected outright. - Owner verification — the caller’s user ID must match the session/runner owner.
- Links are bound to one exposure — a signed path token or tunnel-origin label works only for the exposure of the port that was current when it was minted. If the port is closed (
close_tunnel, session end, service stop) and the same port number is exposed again later, older links return404instead of reaching the new service — even when the port is reused immediately: a link minted before the port was last closed never reaches a later exposure. A link minted after the close (for example while the new exposure is still being set up) works. Restarting the runner daemon also starts new exposures, and links minted before the restart are refused. The runner compares the link’s age with when it closed the port, so on a multi-instance relay with skewed clocks a link minted within that skew of a close can be misjudged; keep relay clocks synchronized (NTP). Re-registering a port that is still open (relay reconnect, service restart) keeps existing links valid. Use Reload in the panel, reopen it, or callcreate_tunnelagain to get a fresh link. Tunnel-origin labels minted before this check existed are refused once. Cookie- and API-key-authenticated requests are authorized on every request, so they are unaffected. - Header stripping —
Cookie,Authorization,X-API-Key, andRefererheaders are never forwarded to the local service, preventing auth-leakage to tunneled apps. - Query param sanitization —
apiKeyandtunnelTokenquery parameters are stripped before forwarding. - No origin-wide state from path tunnels — path-prefix responses are served on the relay’s own origin, so the relay drops
Set-Cookie,Clear-Site-Data,Service-Worker-Allowed,Strict-Transport-Security,Alt-Svc,NEL/Report-To/Reporting-Endpoints, andAccess-Control-Allow-Credentialsfrom them. A local app cannot set, shadow, or clear PizzaPi’s cookies or storage. Apps that need their own cookies must run on the dedicated tunnel origin, where cookies are kept and forced host-only. - Hop-by-hop header removal — standard proxy headers (
Connection,Transfer-Encoding, etc.) andAccept-Encodingare stripped so responses arrive uncompressed for rewriting.
Use Cases
Section titled “Use Cases”Dev Server Preview
Section titled “Dev Server Preview”The most common use case — an agent starts a development server and creates a tunnel so you can preview it in your browser:
Agent: I'll start the Vite dev server and create a tunnel so you can preview it.
> bun run dev → Local: http://localhost:5173/
> create_tunnel(port: 5173, name: "vite-dev") → Public URL: https://relay.example.com/api/tunnel/runner/abc123/5173/The tunnel’s runtime interceptor ensures Vite’s HMR WebSocket connection, dynamic chunk loading, and SPA router navigation all work through the tunnel.
Sharing a Local Service
Section titled “Sharing a Local Service”When the agent sets up a local API server, database UI, or documentation preview, tunnels let you interact with it without SSH or port forwarding:
> create_tunnel(port: 8080, name: "api-server")> create_tunnel(port: 6006, name: "storybook")Use list_tunnels to see all active tunnels at a glance.
SSE and Streaming Endpoints
Section titled “SSE and Streaming Endpoints”Tunnels support Server-Sent Events (SSE) and chunked streaming responses. Content types that need no rewriting — such as SSE, JSON, images, and binary — are streamed directly with minimal latency. In path-prefix mode, HTML, JavaScript/TypeScript modules, and CSS are buffered for rewriting only up to 2 MiB; larger rewritable responses stream through unmodified. Host-based tunnels are unaffected because they do not need URL rewriting.
Example Workflow
Section titled “Example Workflow”A typical agent interaction using tunnels:
User: Set up the Next.js project and let me preview it.
Agent: 1. npm install 2. npm run dev → starts on port 3000 3. create_tunnel(3000, "next-dev") → https://relay.example.com/api/tunnel/runner/r1/3000/ 4. "Here's your preview URL: [link]. The dev server is running with hot reload — changes will appear automatically."
... later ...
5. close_tunnel(3000) → tunnel closed after work is doneLimitations
Section titled “Limitations”- Relay required — the runner must be connected to the relay server. If the relay connection drops, tunnel URLs return
503 Runner not available. - No offline support — tunnels are inherently a network feature. Without a relay, use direct
localhostaccess on the runner machine. - Request timeout — service message round-trips time out after 10 seconds. If the runner daemon is unresponsive, tunnel creation fails with a timeout error.
- Single-user access — only the session/runner owner can access tunnel URLs. There is no sharing with other authenticated users.
- Content rewriting limits (path-prefix mode only) — the proxy rewrites HTML, JS modules, and CSS only while the response is 2 MiB or smaller. Larger rewritable responses stream unmodified: absolute URLs inside them are not rewritten, while root-relative URLs still resolve through the tunnel prefix via the injected
<base>. Other edge cases (e.g. paths constructed entirely at runtime from string concatenation, SPA routers readinglocation.pathname) may not be intercepted. ConfigurePIZZAPI_TUNNEL_DOMAINto bypass rewriting entirely — apps on a dedicated tunnel origin run unmodified, so this cutoff does not apply. - Resource limits — the relay bounds tunnel traffic: request bodies are capped at 100 MiB per request (
413above that), each runner serves at most 256 concurrent tunnelled requests and WebSockets (503above that), and slow consumers apply backpressure — when the viewer stops reading, the runner pauses the local service’s response; when the local service stops reading an upload, the runner asks the relay to pause it (where the runtime reports write backpressure). A stream whose queued data still exceeds 64 MiB is terminated (WebSockets close with code1013). Self-hosters can tune these with thePIZZAPI_TUNNEL_*variables in Self-Hosting. - Local HTTPS targets — the runner probes each exposed port and speaks TLS to local HTTPS services automatically (self-signed certs accepted, loopback only).