An instance's gateway binds to loopback. To reach Gini from a phone or another machine you front the gateway port with a tunnel — one public origin then serves both the web UI and the /api/* surface (see Gateway And Control Plane).
All four tunnel providers are drivable from the web tunnel panel and gini tunnel, and each has its own self-contained guide — the same pages the app opens in a slide-over when a provider needs setting up (tap Connect on a provider that isn't ready and its guide opens; the sidebar's Remote access entries open them directly):
- Gini Relay — managed, zero setup, stable subdomain.
- Tailscale — tailnet-private, stable URL, TLS on your own machine.
- ngrok — public URL, random per connect on the free tier.
- Cloudflare — your own domain via a named tunnel (SSE works), quick-tunnel fallback.
The relay is always available; the other three enable when their host prerequisite is detected (tailscale logged into a tailnet, an ngrok authtoken, the cloudflared binary). Tapping Connect re-checks availability server-side and either connects or rejects with the reason — so a freshly-installed CLI works without restarting anything.
When the runtime drives a tunnel itself, the connected URL's origin is trusted automatically for as long as the record is connected (and revoked the moment it isn't) — no GINI_TRUSTED_ORIGINS needed. The env var remains the knob for manually-run fronts (below).
Terminology: this page is about tunnel providers. The similarly-named
gini connectorsCLI and/api/connectorsroutes manage service connectors (Google, Linear, MCP-backed tools) and are unrelated to remote access.
The runtime keeps one tunnel selection per instance: a selectedProvider plus a connection status (idle, connecting, connected, or error). Drive it with:
gini tunnel # status + provider catalog
gini tunnel select <provider> # save a selection (stays idle)
gini tunnel connect [provider] # connect (provider arg overrides the saved selection)
gini tunnel cancel # abort a pending login, keep the selection
gini tunnel disconnect # tear down the tunnel, keep the selectionEvery command returns the full tunnel state, so any one call tells you whether a mode is selected and what it is. The same state is available to every client at GET /api/tunnel (bearer-gated). The contract and connect flow live in ADR tunnel-connectivity.md.
| Surface | How | What it tells you |
|---|---|---|
| CLI | gini tunnel |
selectedProvider: null means no mode is selected; a provider id means that mode is selected. status + url tell you whether it is actually live. |
| HTTP | curl -H "Authorization: Bearer <token>" http://127.0.0.1:<port>/api/tunnel |
Same TunnelState JSON — the web UI polls this same state through the BFF (/api/runtime/tunnel, token injected server-side). |
| Web UI | Sidebar tunnel pill | Off / no tunnel when nothing is connected; Live / <provider> with a green dot when connected. Opening it shows the provider panel with the selected row marked Selected (or Connected). |
| Disk | state.json → tunnel |
The persisted TunnelSelectionRecord (selectedProvider, status, url, createdAt/updatedAt). Survives restarts. |
| Audit | audit rows tunnel.select, tunnel.connect, tunnel.connected, tunnel.error, tunnel.cancel, tunnel.disconnect, tunnel.reconcile |
Who changed the mode and when, with the provider id in evidence. |
| Logs | ~/.gini/instances/<instance>/logs/runtime.jsonl |
tunnel.connected, tunnel.connect.error, tunnel.exited, tunnel.resume.* events from the background connect/resume flows. |
A fresh instance reads:
{ "providers": [ ... ], "selectedProvider": null, "status": "idle" }After gini tunnel select gini-relay:
{ "providers": [ ... ], "selectedProvider": "gini-relay", "status": "idle" }url appears only when status is connected; message only on error. The selection persists across gateway restarts (a connected tunnel is resumed automatically on boot; see the reconcile section of ADR tunnel-connectivity.md).
GET /api/tunnel is cheap to poll: it reads the persisted record and rebuilds the static catalog in process — no network calls — so the web UI polling it (through the BFF) during connecting costs effectively nothing.
Only catalog-enabled providers can be selected. Selecting or connecting a provider whose prerequisite is genuinely missing is rejected before any state changes with HTTP 400 (CLI exit 1) and the machine-readable code provider_unavailable — the web UI uses that code to open the provider's guide instead of failing silently:
$ gini tunnel select tailscale # on a machine without a tailnet login
Tunnel provider Tailscale is not available (requires Tailscale network).
$ gini tunnel select wireguard
Unknown tunnel provider: wireguard
So: an unavailable provider can never be the selected mode. If you front Gini with a tool the runtime isn't driving (a manual front), gini tunnel intentionally keeps reporting its own selection — the runtime only reports tunnels it manages. Confirm a manual front at its own layer instead (each provider guide shows how).
Switching the selection away from a live provider tears the old tunnel down first (including the persistent tailscale serve config), and a connected front's origin trust always tracks the record — connected = admitted, anything else = revoked.
For anything the drivers don't cover — a reverse proxy, or any front you run yourself (each provider guide has its provider-specific variant of this) — the same recipe always works. The examples below use <gateway-port> for the instance's gateway port — find yours with gini status.
-
Expose the gateway port (not the web port) with your tunnel tool. One origin then serves UI + API. For a durable tunnel, pin the port first: non-default instances derive hash-based ports that walk forward when busy (see Gateway And Control Plane), so after a restart a long-lived tunnel can silently forward to whatever process now owns the old port. Launch with an explicit
--port/GINI_PORT— pinned ports stay strict and fail instead of moving — and stop/recreate the tunnel whenever the port or instance changes. -
Trust the external origin. The gateway fail-closes web-bound requests from hostnames it doesn't know (page navigations 404,
/api/runtime/*403). SetGINI_TRUSTED_ORIGINSto the comma-separated full origins of your tunnels, in the gateway process environment at launch:GINI_TRUSTED_ORIGINS=https://my-mac.tailnet-name.ts.net,https://gini.example.com gini start
To make it durable, add the line
GINI_TRUSTED_ORIGINS=...to~/.gini/secrets.env— the installedginiwrapper sources it on every launch, andgini autostart enablemerges it into the launchd plist (re-run that after editing). If the variable is set but contains no parseable origin, the gateway refuses every web-bound request on a front not covered by the other trust lanes until it's fixed — the typo bricks exactly the manual front you were configuring, loudly rather than silently downgrading. See ADR bff-trust-boundary.md. -
Understand what trust grants. A trusted origin is owner-equivalent: any browser arriving on it gets the same access as loopback — there is no per-device pairing gate (see ADR owner-token-auth.md). Expose a manual front only to devices and networks you fully trust (a tailnet, a password-protected proxy), or use the hosted edge when multiple users need access. Non-browser clients use the native
/api/*surface with the owner bearer fromconfig.json— a singleton with no per-device revocation (revoke by rotating it), so treat any device holding it as the operator.
The full decision table — identical across every tunnel:
| Probe through the tunnel | Origin not trusted | Origin trusted |
|---|---|---|
Page navigation (/) |
404 |
200 (serves the app) |
GET /api/runtime/__healthz (web-bound) |
403 |
200 |
GET /api/tunnel with bearer (native API) |
200 |
200 |
The third row is the discriminator when something is wrong: the native bearer-gated API ignores the origin gate, so a 200 there means the tunnel and gateway are healthy and any 404 on pages is the origin gate (for a runtime-driven tunnel that means it is no longer connected; for a manual front, fix GINI_TRUSTED_ORIGINS). If even the bearer probe fails, the tunnel itself is broken. Provider-specific symptoms live in each provider's guide.