@tafrahq/wt-tunnel
v0.7.0
Published
Sets up the recommended wt-tunnel server on Fly.io, with optional Cloudflare DNS: one config file, secrets from the environment, and an Alchemy stack shipped inside.
Maintainers
Readme
wt-tunnel
wt-tunnel sets up the recommended wt-tunnel server: one Fly.io Machine running the published server image, with its DNS records in Cloudflare or created by you. A deployment is two things in one directory:
wt-tunnel.deploy.jsonsays where and how the server runs: the Fly app, the image, the tunnel host, the DNS zones and where Alchemy keeps its state. It holds no secrets.- The server folder,
.server/, says what the server does. It holds the server configwt-tunnel-server.json, the root CA'sca.crtandca.key, and anything else the server reads. The firstwt-tunnel deploycreates it. Every deploy uploads it to the Machine as it is.
The CLI checks both, then runs an Alchemy stack that ships inside the package. You do not install Alchemy yourself. The stack creates:
- a Fly app in your organization, with a dedicated IPv4 (billed by Fly) and an IPv6;
- one Fly secret per file in the server folder, which Fly writes to
/etc/wt-tunnel-server/in the Machine; - one Machine (shared CPU, 512 MB by default) running
ghcr.io/tafrahq/wt-tunnel-serverwith--config /etc/wt-tunnel-server/wt-tunnel-server.json, with raw TCP on 443, 80 and 7000, so the server ends TLS itself; - the DNS records:
*.<domain>for each dev domain, the tunnel host and the sign-in host, all pointing at the Machine directly. The stack creates the records in Cloudflare zones. For amanualzone, the CLI lists the records for you to create instead.
A change to any file in the server folder rolls the Machine, because the Machine's environment carries each file secret's digest. A new image tag also rolls it.
Install
npm install -g @tafrahq/wt-tunnelIt needs Node.js 22.22.3 or later (Alchemy's TypeScript loader needs it), and openssl to sign a new root CA.
Set up a deployment
Work in a private directory of your own. It holds the deploy file, the server folder and, with local state, Alchemy's record of what it created.
1. Write the deploy file
mkdir my-wt-tunnel && cd my-wt-tunnel
wt-tunnel initinit writes a commented wt-tunnel.deploy.json, adds .server/ and .alchemy/ to .gitignore, and lists the secrets to create. Edit the file. This is a complete example. Its names sit in three zones: two Cloudflare zones, in any accounts, and one zone whose records you create yourself.
{
"fly": { "org": "my-org", "app": "wt-tunnel-my-org", "region": "ams" },
"tunnelHost": "wt-tunnel.example.com",
"dns": [
{
"provider": "cloudflare",
"zone": "example.com",
"zoneId": "fedcba9876543210fedcba9876543210"
},
{
"provider": "cloudflare",
"zone": "example.net",
"zoneId": "0123456789abcdeffedcba9876543210"
},
{ "provider": "manual", "zone": "example.org" }
],
"state": { "provider": "cloudflare", "accountId": "0123456789abcdef0123456789abcdef" }
}| Field | Required | Meaning |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| fly.org | yes | The Fly organization slug, as fly orgs list shows it. |
| fly.app | yes | The Fly app name. Fly app names are global. |
| fly.region | yes | The Fly region, such as ams or iad. |
| fly.memoryMb | no | The Machine's memory. Default 512. |
| tunnelHost | yes | The host tunnel clients connect to. A new root CA is named after it. |
| dns | yes | One entry per zone, each with a provider and the zone name. { "provider": "cloudflare", "zone", "zoneId" }: the stack creates the records in that Cloudflare zone. { "provider": "manual", "zone" }: you create them, and wt-tunnel outputs lists them. Each record (*.<domain> for each dev domain, the tunnel host and the sign-in host) goes in the most specific zone its name sits in. A name that no zone covers, or a zone listed twice, fails. |
| state | yes | { "provider": "cloudflare", "accountId" } keeps Alchemy's state in a state store Worker in that Cloudflare account, so any machine with the secrets can deploy. "local" keeps it in .alchemy/ next to the deploy file. Back that directory up and keep it out of git: updating or destroying needs it. |
| image | no | The server image. Default: ghcr.io/tafrahq/wt-tunnel-server:<version>, where <version> is this CLI's version. |
| serverDir | no | The server folder, relative to the deploy file. Default .server. |
| stack, stage | no | Alchemy's names for the deployment. Default wt-tunnel-server and prod. |
The file may contain // and /* */ comments. Pass --config <file> to use another path.
Cloudflare is optional. This deploy file needs no Cloudflare account: you create the DNS records yourself, and Alchemy keeps its state in .alchemy/. Sign-in can use Cloudflare Access or not, independently.
{
"fly": { "org": "personal", "app": "wt-tunnel-my-team", "region": "ams" },
"tunnelHost": "wt-tunnel.example.com",
"dns": [{ "provider": "manual", "zone": "example.com" }],
"state": "local"
}2. Create the secrets
Every command needs these in the environment. A secret manager that exports environment variables, such as fnox, 1Password CLI or Doppler, works well.
| Variable | How to create it |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FLY_API_TOKEN | fly tokens create org <org>. An org token can manage every app in the organization. |
| CLOUDFLARE_API_TOKEN | Needed when a dns entry or the state uses Cloudflare. In the Cloudflare dashboard, under My Profile, API Tokens, create a custom token with Zone, DNS, Edit on every Cloudflare zone in dns. With Cloudflare state, also give it Account permissions Workers Scripts Edit, Account Settings Edit, Secrets Store Edit and Workers Tail Read on the state's account. |
| CLOUDFLARE_ACCOUNT_ID | Only with a Cloudflare zone in dns and "state": "local": the ID of an account that holds one of those zones. Alchemy's Cloudflare credentials need an account even though DNS records need only the zone ID. With Cloudflare state, the CLI sets it from state.accountId. |
Alchemy takes one set of Cloudflare credentials per deployment, so one CLOUDFLARE_API_TOKEN must be able to edit every Cloudflare zone in dns. A user API token, created under My Profile, can cover zones in several accounts. An account API token covers only its own account. A missing variable fails the command and names it.
The server's own secrets live in the server folder. The first deploy reads these only to create it:
| Variable | Use |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| Sign-in providers | At least one, or chosen and typed at the prompt. A provider is set when its client ID is. See Sign-in providers. |
| WT_TUNNEL_SERVER_TOKEN, WT_AUTH_SECRET | Optional. Without them, the deploy generates each one: 32 random bytes, base64url. |
| WT_CA_KEY or WT_CA_KEY_FILE | Optional: an existing root's key, as PEM text or a file. See The root CA. |
Sign-in providers
Set up one or more. Each provider's redirect URI is https://<authHost>/api/auth/callback/<provider>. With several, the sign-in page lets people choose.
| Provider | Variables | Where to create the client |
| ----------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Google | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Google Cloud console, APIs and Services, Credentials: an OAuth client ID of type Web application. |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | GitHub, Settings, Developer settings, OAuth Apps. The redirect URI is the authorization callback URL. |
| Microsoft | MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, optional MICROSOFT_TENANT_ID | Microsoft Entra admin center, App registrations, with a Web redirect URI and a client secret. Under Token configuration, add the optional ID token claim verified_primary_email. The tenant ID limits sign-in to one tenant (default: any account). |
| Cloudflare Access | CLOUDFLARE_ACCESS_CLIENT_ID, CLOUDFLARE_ACCESS_CLIENT_SECRET, CLOUDFLARE_ACCESS_TEAM_DOMAIN | Cloudflare Zero Trust, Access, Applications: a SaaS application with OIDC. The team domain is like acme.cloudflareaccess.com. |
To add or remove a provider later, edit providers in .server/wt-tunnel-server.json and deploy again.
3. Deploy
wt-tunnel deploy --domain dev.example.com --auth-host auth-wt-tunnel.example.com \
--allow-email [email protected] --allow-email-domain example.comThe first deploy creates .server/:
--domain(repeatable): the dev domains. Worktree URLs arehttps://<app>.<worktree>.<domain>.--auth-host: the sign-in host, shared by every dev domain. It must sit outside every dev domain. See the sign-in host.--allow-emailand--allow-email-domain(both repeatable): who may sign in. At least one.
At a terminal, the deploy asks for any of these you leave out, and for the sign-in providers and their clients, with each secret's input hidden. Without a terminal, as in CI, it fails and names every missing flag and variable. It then writes the folder, mode 0700, with wt-tunnel-server.json and ca.key readable by you only, and prints what it created and what it generated.
wt-tunnel prepare takes the same flags and does only this step: it creates the folder, or checks the one there, and touches nothing in the cloud. wt-tunnel plan previews the folder it would create without writing anything, and shows the plan. The plan reads your Fly organization, so it needs a valid FLY_API_TOKEN.
To try the server before you deploy it, run the folder prepare wrote on your machine, on ports that need no root, with wt-tunnel-server:
wt-tunnel-server start --config .server --detach --https-port 8443 --http-port 8081
curl --cacert .server/ca.crt --resolve app.feat-x.dev.example.com:8443:127.0.0.1 \
-H 'Accept: text/html' -i https://app.feat-x.dev.example.com:8443/
wt-tunnel-server stopThe curl gets a redirect to the sign-in host, which shows that TLS from your root, the sign-in check and the routing work.
Then the deploy shows the plan, asks for approval, and applies it. deploy --yes applies without asking. Without a terminal to ask in, deploy and destroy fail unless you pass --yes. A second deploy with nothing changed does nothing. After a deploy, the CLI prints the app, its IPs, the tunnel host, the CA page, and which zone holds each DNS record. For a manual zone, it lists the records to create. Run wt-tunnel outputs to print them again.
wt-tunnel destroy deletes everything the stack created. It leaves the server folder alone.
For anything else Alchemy can do, wt-tunnel alchemy -- <command> runs that alchemy command against this deployment's stack, with the same checks and environment. For example, wt-tunnel alchemy -- state ls wt-tunnel-server/prod --recursive lists what the state holds.
The sign-in host
Each device opens the sign-in host (--auth-host, authHost in the config) before it trusts your root: that is where the /ca page lives. Browsers must trust the certificate there, and your root cannot vouch for it, because the root is name-constrained to the dev domains. So the server gets a public certificate for that one name from Let's Encrypt each time it starts, over ports 80 and 443, which the stack opens.
Let's Encrypt issues at most five certificates for the same name in a week, and the server keeps none across restarts. After a sixth deploy or restart in a week, the sign-in host falls back to an untrusted certificate until the week is over, and nobody can sign in. Batch config changes into fewer deploys.
A deployment from an earlier version had the sign-in record proxied by Cloudflare. The next deploy makes it DNS only, like the others.
4. Trust the root on each device
After the deploy, open https://<authHost>/ca on every device that opens dev URLs. wt-tunnel outputs prints the address as CA page. The page shows the root's fingerprint and permitted names, offers ca.crt and an iPhone and iPad profile for download, and gives the trust steps for the device's platform first.
To trust the root from the file instead, on macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain .server/ca.crt. The page lists the steps for the other platforms.
5. Point the clients at the server
In each repository's wt-tunnel.json, set domain to a dev domain and server.host to the tunnel host:
{
"domain": "dev.example.com",
"server": { "host": "wt-tunnel.example.com" },
"apps": { "app": { "portEnv": "APP_PORT" } }
}Each developer sets WT_TUNNEL_SERVER_TOKEN to tunnelToken from .server/wt-tunnel-server.json. See the wt-tunnel client.
The server folder
[!WARNING] The server folder holds the server's secrets in plain text: the sign-in providers' client secrets, the tunnel token, the auth secret and the root CA's key. Never commit it. Back it up somewhere safe, such as a secret manager: without
ca.key, a new root means every device must trust it again.
To change what the server does, edit .server/wt-tunnel-server.json and run wt-tunnel deploy again. docs/server-mode.md lists its fields. A deploy never rewrites the folder. It checks it first, and any problem stops the deploy before anything changes in the cloud:
- the config decodes with the server's own schema, and every problem is listed at once;
ca.certandca.keyname files in the folder that hold a PEM certificate and key;- the root's name constraints cover every domain, and the key is the certificate's;
- every DNS record name sits in a zone in
dns; - the sign-in host is outside every dev domain;
- no one else may read
wt-tunnel-server.jsonorca.key; - the folder holds only files, with names of letters, digits,
.,_and-.
A file the config does not name is uploaded as well, and the deploy says so. The Machine reads every file from /etc/wt-tunnel-server/, so a later server version can read a new file there without a change to the stack. Fly writes each file with mode 0444, readable by every user in the Machine. Fly does not document who owns the files it writes, and the server runs as an unprivileged user, so 0600 could leave them unreadable. A tighter mode would hide nothing anyway: Fly puts every app secret in the environment of the Machine's processes.
The root CA
Dev hosts use certificates from the deployment's own root, an EC P-256 root valid for 10 years with CA:TRUE, pathlen:1 and critical name constraints. The root can issue certificates only for the dev domains and the names under them, so a device that trusts it trusts nothing else. It is named wt-tunnel root (<tunnelHost>). The name is only a label; the constraints decide what it can sign.
When the first deploy creates the folder, it decides the root so that a root devices already trust is never replaced by accident:
- The certificate comes from
--ca-cert, elseca.crtin the server folder, elseca.crtnext to the deploy file. - The key comes from
--ca-key,WT_CA_KEY,WT_CA_KEY_FILE, elseca.keyin the same two places. - With both, the deploy copies them into the folder. With neither, it makes a new root and writes it only into the folder. With only one, it stops: a new root in its place would not be trusted by the devices that trust the old one.
wt-tunnel ca create [<domain>...] makes a root ahead of the deploy. It writes ca.crt and ca.key where the deploy reads them, in the deploy file's server folder (or where an existing wt-tunnel-server.json points its ca), with the folder 0700 and the key 0600. The domains default to the server config's, and at a terminal it asks for them. It refuses to replace an existing root unless you pass --force, because every device must then trust the new one. --out-dir writes elsewhere, and --name sets the label when there is no deploy file.
Upgrade
Each release of this CLI deploys the server image of the same version. To upgrade, install the new CLI and run wt-tunnel deploy. To stay on an image, or to try another, set image in the deploy file.
How it runs Alchemy
The package depends on alchemy and its Fly and Cloudflare providers, and ships the stack as dist/stack.js. Each command decodes the deploy file, checks the environment and the server folder. It then starts the alchemy launcher from its own dependency with Node, for example alchemy deploy <dist/stack.js> --stage prod, in the deploy file's directory. The stack reads the checked deployment and the folder's files from two environment variables that only the CLI sets. Alchemy's own output and approval prompt appear in your terminal as usual.
Alchemy's Fly.Machine has no files. The stack registers its own provider for the same Fly.Machine type, which sends the files and treats a change to them as drift, and keeps Alchemy's read, diff and delete. A Machine deployed before keeps its state and updates in place.
Develop
pnpm check at the repository root formats, lints, type-checks and tests this package with the rest of the monorepo. pnpm turbo run build --filter @tafrahq/wt-tunnel builds dist/. To run the workspace build, start it from your deployment directory:
cd /path/to/my-wt-tunnel
node /path/to/wt-tunnel/apps/cli/dist/bin.js plan