@tafrahq/wt-tunnel-client
v0.7.0
Published
Private HTTPS URLs for every git worktree, through a self-hosted frp tunnel server behind a sign-in (Google, GitHub, Microsoft or Cloudflare Access): one frpc per worktree, no ports in the URLs, no root on the dev machine.
Maintainers
Readme
wt-tunnel-client
wt-tunnel-client is a command-line tool that gives each git worktree its own private HTTPS URLs through a tunnel server you run yourself. Run wt-tunnel-client worktree add myproject feat-x in a worktree and your dev servers open at https://app.feat-x.dev.example.com in any browser, after a sign-in (Google, GitHub, Microsoft or Cloudflare Access) that only the people you allow can pass.
The URLs have no port, the apps stay plain HTTP on 127.0.0.1, and the dev machine needs no root access and opens no port. Each worktree runs one frpc, which connects out to the server.
browser https://app.feat-x.dev.example.com
server Caddy :443 ends TLS, asks the auth service, then hands the request to frps
frps routes by Host to the frpc that registered it
frpc (this worktree) app.feat-x.dev.example.com → http://127.0.0.1:4123docs/server-mode.md describes the design and the sign-in flow. apps/cli/README.md deploys a server to Fly.io, and apps/server/README.md runs it anywhere else.
Install
npm install -g @tafrahq/wt-tunnel-client # or add it as a devDependencyThe package is on npm from 0.7.0. Earlier versions are on GitHub Packages, which needs a GitHub token to install from.
Up to 0.4.0 the client was published as @tafrahq/wt-tunnel; from 0.5.0 that name is the server setup CLI, so install @tafrahq/wt-tunnel-client.
Requirements:
- Node.js 22 or later.
- Linux or macOS, on amd64 or arm64, for the machine that runs the worktrees. Windows is not supported.
- A running wt-tunnel server, and its tunnel token.
Configure
Put wt-tunnel.json at the repository root and commit it, so every worktree checks it out:
{
"domain": "dev.example.com",
"server": { "host": "wt-tunnel.example.com" },
"apps": {
"app": { "portEnv": "APP_PORT" },
"api": { "portEnv": "API_PORT" },
"db": { "portEnv": "DB_PORT", "route": false }
}
}| Field | Default | Meaning |
| ------------- | -------- | -------------------------------------------------------------------------------------- |
| domain | required | Parent domain of the URLs. It must be one of the server's domains. |
| server.host | required | The tunnel server's host name. An IP address also works. |
| server.port | 7000 | The port where the server accepts frpc. |
| apps | required | One entry per service. portEnv names the variable that receives its port. See below. |
Each entry in apps accepts:
| Field | Default | Meaning |
| --------- | -------- | ------------------------------------------------------------------------------------------------------ |
| portEnv | required | Environment variable that receives the service's port, such as APP_PORT. |
| route | true | false for services that need a port but no URL, such as a database. |
| port | reserved | A fixed port, if the service must keep a known one. worktree add fails if another worktree holds it. |
Each app is served at https://<label>.<worktree>.<domain>. An app labelled root is served at the bare worktree host, https://<worktree>.<domain>, which suits a site with a single app:
{
"domain": "dev.example.com",
"server": { "host": "wt-tunnel.example.com" },
"apps": { "root": { "portEnv": "PORT" }, "api": { "portEnv": "API_PORT" } }
}This gives https://feat-x.dev.example.com and https://api.feat-x.dev.example.com.
Set WT_TUNNEL_SERVER_TOKEN to the server's tunnel token (tunnelToken in its config). wt-tunnel-client passes it to frpc in the environment and never writes it to disk.
Trust the server's certificate
The server signs its certificates with its own root CA, so no worktree name ever appears in a public Certificate Transparency log. Each device that opens the URLs must trust that root once. It is name-constrained to the dev domains, so it can vouch for nothing else.
The easiest way is to open https://<authHost>/ca on the device, where <authHost> is the server's sign-in host (the host the sign-in page redirects to). The page shows the root's fingerprint and permitted names, downloads the certificate (or, on iPhone and iPad, a profile), and lists the steps for each platform with yours first. To do it by hand with the deployment's ca.crt:
- macOS.
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crt. - iOS and iPadOS. Open the file in Safari, install the profile from Settings > Profile Downloaded, then turn on full trust in Settings > General > About > Certificate Trust Settings.
- Linux. On Debian and Ubuntu, copy it to
/usr/local/share/ca-certificates/wt-tunnel.crtand runsudo update-ca-certificates. On Fedora and RHEL, copy it to/etc/pki/ca-trust/source/anchors/and runsudo update-ca-trust. Some browsers keep their own certificate store. - Windows. Open the file, select Install Certificate, choose Current User, and place it in Trusted Root Certification Authorities.
Commands
wt-tunnel-client never runs git and never picks names. Create the git worktree yourself, then name the project and the worktree:
git worktree add .worktrees/feat-x && cd .worktrees/feat-x && wt-tunnel-client worktree add myproject feat-xwt-tunnel-client worktree add <project> <worktree>Provisions the worktree root, which is the nearest directory at or above the current one that holds wt-tunnel.json. It reserves the worktree's ports, writes .wt-tunnel/frpc.toml with one HTTP proxy per routed app, starts frpc, and waits until the server has accepted every host. It prints the URLs and ports. Both names must be DNS labels (lowercase letters, digits and interior hyphens, at most 63 characters); wt-tunnel-client rejects other names and never rewrites them. Running it again with the same names is safe. If the root is already provisioned under other names, the command fails; run wt-tunnel-client worktree remove first.
If the server refuses a host, for example because another worktree already serves it, the command stops frpc and fails, naming the host and the reason.
wt-tunnel-client worktree removeStops frpc, releases the ports, and deletes .wt-tunnel/. It reads the names from the root's state. Then remove the checkout yourself with git worktree remove .worktrees/feat-x.
Run these from inside a provisioned worktree:
wt-tunnel-client up # provision and start this worktree, for example after a reboot
wt-tunnel-client down # stop frpc, keep the ports
wt-tunnel-client status # server, frpc state (running / stopped / stale), URLs
eval "$(wt-tunnel-client env)" # export APP_PORT=… for your dev serversThese commands fail with run `wt-tunnel-client worktree add <project> <worktree>` first in a root that is not provisioned. If the config has not changed, up leaves a running frpc alone; otherwise it restarts frpc with the new config.
The dev servers must listen on 127.0.0.1 at their allocated ports. For example, a Vite app can use vite --host 127.0.0.1 --port "$APP_PORT" --strictPort. Vite must also accept the hostname: add server.allowedHosts: ['.dev.example.com'] to its config. WebSocket upgrades pass through, so HMR works.
Ports and URLs between services
wt-tunnel-client picks every port at worktree add, keeps it for the life of the worktree, and writes it to .wt-tunnel/env, together with WT_TUNNEL_HOST=<worktree>.<domain>.
Ports are reserved per user in ~/.local/state/wt-tunnel/ports/, one file per port naming the worktree that owns it. A stopped worktree keeps its ports, so another worktree is never handed them. Reserved ports come from 20000-29999, below the range Linux uses for outgoing connections. worktree remove releases them, and reservations of a worktree deleted without remove are reclaimed automatically.
Write service URLs once in mise.toml. mise fills in the ports from the env file, in every worktree:
[env]
_.file = { path = ".wt-tunnel/env", tools = false }
DATABASE_URL = "postgres://127.0.0.1:{{ env.DB_PORT | default(value='') }}/app"
API_URL = "http://127.0.0.1:{{ env.API_PORT | default(value='') }}"
VITE_API_URL = "https://api.{{ env.WT_TUNNEL_HOST | default(value='localhost') }}"Services on the dev machine talk to each other over 127.0.0.1. The browser uses the https://<app>.<worktree>.<domain> names.
The frpc binary
On first use, wt-tunnel-client downloads frp v0.71.1 for the platform from GitHub. It checks the download against a SHA-256 pinned in the source, then installs frpc in $XDG_CACHE_HOME/wt-tunnel/frp/0.71.1/ (by default ~/.cache on Linux and ~/Library/Caches on macOS). To use your own frpc binary, set WT_TUNNEL_FRPC.
Local state
Local state lives in <worktree root>/.wt-tunnel/, which ignores itself in git. It contains:
state.json, with the project and worktree names and the reserved portsenvfrpc.toml, without the tokenfrpc.pidandfrpc.log
The frpc log records each host the server accepted or refused. It is the first place to look when a URL does not load.
Limits
- Two projects that share a
domaincannot use the same worktree name at the same time. The server refuses the second worktree's hosts. Projects with different domains can use the same worktree name. - Every URL is reachable from the internet, behind the sign-in. The server admits only the emails and email domains in its
allowlist.
Development
This package is apps/client in the wt-tunnel monorepo, which describes how to install, check and build it. pnpm build here runs tsdown unbundled: one dist file per source file, with the dependencies external. The workspace-only @tafrahq/wt-tunnel-contract package is compiled into dist/, so it is not a dependency of the published package.
The package ships the built dist/, the TypeScript sources in src/ (source maps point at them), and llms.txt, the same layout Effect uses for its packages.
Releases publish this package together with the server and the wt-tunnel CLI, at one version. CONTRIBUTING.md describes how.
