Skip to content

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.


When the agent creates a tunnel, the following happens:

  1. The agent calls create_tunnel with a local port number.
  2. A service_message is sent over the relay’s Socket.IO connection to the runner daemon’s TunnelService.
  3. The daemon registers the port and confirms the tunnel.
  4. 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, and OPTIONS requests 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 patches fetch, XMLHttpRequest, EventSource, WebSocket, history.pushState, history.replaceState, location.assign, location.replace, navigator.sendBeacon, window.open, Element.prototype.setAttribute, and dynamic resource loading at runtime.

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.

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 with Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads. There is no allow-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 own Content-Security-Policy is kept alongside the sandbox. On signed paths and the dedicated tunnel origin, which the UI frames cross-origin, the relay drops X-Frame-Options and removes only the frame-ancestors directive 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, fetch from the UI) are served directly. The signed path answers CORS for the sandbox’s null origin, so the app’s own fetch, module scripts, and fonts keep working.
  • Storage is per page load. localStorage, sessionStorage, and document.cookie throw 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.

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:

Terminal window
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:

Terminal window
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 pizza web

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:

Terminal window
PIZZAPI_TUNNEL_DOMAIN=https://t.example.com:8444 PIZZAPI_CADDY=1 pizza web

The 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.


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 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 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
}

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 to BETTER_AUTH_SECRET so 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_PREVIOUS to 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, and create_tunnel/list_tunnels (or POST /api/tunnel-token with ttlHours) 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.
  • Token claims — every token carries aud (pizzapi:tunnel), iat (issued-at), kid (key identifier derived from the active secret), and exp. On verify: aud, kid, and exp are all enforced. A token whose kid does 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 return 404 instead 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 call create_tunnel again 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, and Referer headers are never forwarded to the local service, preventing auth-leakage to tunneled apps.
  • Query param sanitization — apiKey and tunnelToken query 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, and Access-Control-Allow-Credentials from 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.) and Accept-Encoding are stripped so responses arrive uncompressed for rewriting.

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.

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.

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.


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 done

  • 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 localhost access 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 reading location.pathname) may not be intercepted. Configure PIZZAPI_TUNNEL_DOMAIN to 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 (413 above that), each runner serves at most 256 concurrent tunnelled requests and WebSockets (503 above 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 code 1013). Self-hosters can tune these with the PIZZAPI_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).