A 100% browser-based SSH terminal to Tailscale machines using Tailscale's WASM, deployed with Cloudflare Workers.
This is internal-use software. There is no authentication layer in front of the app itself. Whoever can reach the deployed URL can attempt to start a Tailscale session under your tailnet. Do not deploy to a public domain without adding access controls (e.g. Cloudflare Access, IP allowlisting, or keeping the Worker route private).
TailSSH connects to machines using Tailscale SSH — certificate-based SSH that requires no passwords and no key distribution. You must enable it on every machine you want to reach.
Full documentation: https://tailscale.com/docs/features/tailscale-ssh
Quick steps per target machine:
# Linux / macOS — enable Tailscale SSH
sudo tailscale set --ssh
# Verify it is active
tailscale status
# Look for "offers SSH" next to the machine entryOn the Tailscale admin console you also need an ACL rule that grants your user
(or a tag) SSH access. The minimal addition to your acls block:
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}Adjust src, dst, and users to match your security policy.
git clone <your-repo-url>
cd tailssh
npm install
npm run build # vendors pkg.js / main.wasm / pkg.css into public/-
Create
.dev.varsin the project root (this file is gitignored):TS_API_TOKEN=tskey-api-<your-token-here>Generate a token at https://login.tailscale.com/admin/settings/keys — scope it to read-only for devices.
-
Start the local dev server:
npm run dev
Wrangler serves the app at
http://localhost:8787. -
Open
http://localhost:8787in your browser. The Tailscale WASM node will boot, open a Tailscale login tab, and authenticate as an ephemeral node.
If this is your first deploy, Wrangler will create the Worker automatically.
The name is set in wrangler.jsonc ("name": "tailssh"). Change it there if
you want a different subdomain.
npx wrangler secret put TS_API_TOKEN
# Paste your tskey-api-… token when promptedThe token is stored encrypted in Cloudflare and is never exposed to the browser.
npm run deployWrangler will print the deployed URL, which will be:
https://tailssh.<your-cf-subdomain>.workers.dev
To use your own domain instead of the *.workers.dev URL:
- In the Cloudflare dashboard, open Workers & Pages → tailssh → Settings → Domains & Routes.
- Add a route or custom domain pointing to the Worker.
- Ensure the domain is proxied through Cloudflare (orange-cloud in DNS).
Reminder: a custom domain makes the app easier to find. If your domain is publicly resolvable, add an access control layer (Cloudflare Access is free for up to 50 users) before sharing the URL with anyone.
- The
TS_API_TOKENsecret is only used server-side in the Worker to callGET /api/devices. It is never sent to the browser. - Each browser session creates an ephemeral Tailscale node that disappears from your tailnet automatically when the tab is closed.
- SSH credentials are certificate-based via Tailscale SSH — no passwords are stored or transmitted by this app.
- Tailscale ACLs govern which users can SSH into which machines. TailSSH does not bypass them.
tailssh/
├── build.js # Asset vendor script — see below
├── package.json
├── wrangler.jsonc # Cloudflare Workers config
├── .dev.vars # Local secrets — gitignored
├── src/
│ └── worker.js # Cloudflare Worker (API proxy)
└── public/
├── index.html
├── style.css
├── app.js # Browser entry point
├── pkg.js # @tailscale/connect ESM bundle (build output)
├── main.wasm # Tailscale WASM binary (build output)
└── pkg.css # xterm.js base styles (build output)
There is no Webpack/Vite/esbuild in this project. build.js is a plain Node
ESM script that copies three files out of node_modules/@tailscale/connect into
public/:
| File | What it is |
|---|---|
pkg.js |
Self-contained ESM bundle — includes xterm.js, FitAddon, WebLinksAddon, the wasm_exec shim, and the createIPN / runSSHSession exports. |
main.wasm |
The Go WASM binary (~32 MB). Kept as a separate file so the browser can use WebAssembly.instantiateStreaming() — inlining it into JS would break streaming compilation and exceed size limits. |
pkg.css |
xterm.js base stylesheet shipped by @tailscale/connect. |
pkg.js is already a self-contained bundle; re-bundling it through a tool like
esbuild would break its internal relative path resolution for main.wasm. The
build step is intentionally just a file copy.
npm run build (and therefore npm run dev / npm run deploy) runs build.js
automatically. You only need to re-run it manually if you update the
@tailscale/connect package.
The Tailscale WASM bundle is pinned to a specific @tailscale/connect version
in package.json. To update:
npm install @tailscale/connect@latest
npm run buildTest locally before deploying — the createIPN / runSSHSession API surface
can change between releases.