Secure tunnels for agents, automation, and worktrees.
Expose local HTTP(S) and TCP services through your own relay, Tailscale, Cloudflare,
or all three at once—with stable worktree URLs and automation-first APIs.
Homebrew adds nikuscs/tap automatically. Wormhole is a formula; the unrelated wormhole cask is
not this project.
brew install nikuscs/tap/wormholeUpdate or remove it:
brew upgrade wormhole
brew uninstall wormholeStandalone installer:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nikuscs/wormhole/releases/latest/download/wormhole-cli-installer.sh | shBuild both binaries from source:
make installnpx skills add nikuscs/wormhole --skill wormhole-cliUse --global for all projects. Update either installation with:
npx skills update wormhole-cli
npx skills update wormhole-cli --globalWorks with Claude Code, Codex, Cursor, GitHub Copilot, Pi, and other Agent Skills clients.
Show or create this machine's identity:
wormhole key showCreate an invite on the relay, then add it on the client:
# On the relay server (single use, 10-minute default expiry):
wormholed invite create --name laptop
# On the client:
wormhole remote add myvps tun.example.com:443 --invite <token>
wormhole domainsRun wormhole remote add alone for the interactive wizard. Scripts must pass NAME, ADDR, and
the invite. Useful relay commands:
- Reusable invite:
wormholed invite create --name personal-devices --reusable - Inspect invites:
wormholed invite ls - Revoke one:
wormholed invite revoke <invite-id> - Break-glass authorization:
wormholed key authorize "<public-key>" --name laptop
Invites are shown once and stored only as digests. The first relay becomes the default;
wormhole domains lists its public domains. Control uses QUIC with secure WebSocket fallback.
Config lives at ~/.config/wormhole/config.toml; keys live at
~/.config/wormhole/keys/. Override config with WORMHOLE_CONFIG or --config PATH.
Existing service:
wormhole http 3000Example result: https://misty-otter-3f2a.tun.example.com.
Development command:
wormhole run -- bun run devwormhole run sets PORT, starts the child, detects its listener, exposes it, and cleans up on exit.
It injects WORMHOLE_URL, APP_URL, and VITE_APP_URL, plus detected framework aliases:
NEXT_PUBLIC_{APP,SITE}_URL, NUXT_PUBLIC_{APP,SITE}_URL, PUBLIC_{APP,SITE}_URL, or
EXPO_PUBLIC_APP_URL. Injected values override project .env files. Compatible --port and
--host flags are supplied for common frameworks; explicit flags win.
Temporary relay endpoints set X-Robots-Tag to noindex, nofollow, noarchive, nosnippet. This is
not access control.
Declarative worktree project:
wormhole upThis starts the current worktree's wormhole.toml services.
A Wormhole relay is optional for Tailscale and Cloudflare:
wormhole http 3000 --endpoint tailscale
wormhole http 3000 --endpoint tailscale:funnel
wormhole http 3000 --endpoint cloudflare:quick
wormhole http 3000 --endpoint cloudflare:named --host app.example.com --persist
wormhole http 3000 --endpoint wormhole --endpoint tailscale --endpoint cloudflareTailscale uses the local tailscaled login. Cloudflare quick tunnels need no login; named tunnels
use cloudflared tunnel login.
Vite receives the public host through __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS.
For Next.js, derive allowedDevOrigins in next.config.js from the injected WORMHOLE_URL:
const wormholeHost = process.env.WORMHOLE_URL
? new URL(process.env.WORMHOLE_URL).hostname
: undefined
module.exports = {
// Preserve any existing Next.js configuration here.
allowedDevOrigins: wormholeHost ? [wormholeHost] : [],
}Or allow a dedicated static namespace:
module.exports = {
allowedDevOrigins: ['*.wormhole.example.com'],
}Merge with existing entries. Use only a Wormhole preview namespace. See the Next.js documentation.
Stable identities require no config. Wormhole derives them from the package.json name (or
folder), service, and Git branch:
{
"scripts": {
"dev:app": "vite",
"dev": "wormhole run -- bun run dev:app"
}
}tun.example.com below represents your relay domain:
| Checkout/worktree | Command | Generated URL |
|---|---|---|
Dashboard main |
wormhole run -- bun run dev |
https://dashboard.tun.example.com |
Dashboard feat/theme-editor |
wormhole run -- bun run dev |
https://dashboard-feat-theme-editor.tun.example.com |
Dashboard fix/mobile-nav |
wormhole http 3000 |
https://dashboard-fix-mobile-nav.tun.example.com |
Docs site main |
wormhole run -- npm run dev |
https://docs-site.tun.example.com |
Rust API feat/health-check |
wormhole run -- cargo run |
https://rust-api-feat-health-check.tun.example.com |
The default branch suffix and port are omitted. The relay reserves the label, Tailscale gets a deterministic HTTPS port, and Cloudflare named tunnels use the same label.
Set the Cloudflare DNS zone in the environment or .env:
WORMHOLE_DOMAIN=preview.example.comOptional defaults:
[defaults]
domain = "preview.example.com"
drivers = ["tailscale", "cloudflare:named"]
tailscale_https_port_range = { start = 20000, end = 49999 }
# Set false to opt out of automatic stable identities.
stable_worktree_urls = falsePriority: explicit --host/--public-port/wormhole.toml, then WORMHOLE_DOMAIN, .env, and
shared config. Use wormhole.toml for multi-service wormhole up projects.
The Worker relay in crates/wormholed-cloudflare provides signed
WebSocket control, invite enrollment, stable HTTP hosts, streaming, edge auth, and Durable Object
state.
Deploy to a dedicated namespace:
wormhole relay deploy cloudflare --domain wormhole.example.com--domain selects the namespace. This example uses relay.wormhole.example.com for control and
myapp.wormhole.example.com for apps without touching example.com, www.example.com, or
stuff.example.com. Using --domain example.com creates myapp.example.com but can intercept
existing *.example.com hosts.
The command verifies its versioned bundle, runs pinned Wrangler, sends generated secrets through
stdin, and rolls back failed deployment/onboarding. Use --dry-run or --bundle PATH for local or
offline validation. Production needs a Cloudflare API token, active zone, Node.js/npm, and Durable
Objects. Workers Logs are off by default.
Custom Domains do not support the wildcard needed for generated app hosts. For
--domain wormhole.example.com, create:
| Type | Name | Target | Proxy status |
|---|---|---|---|
| A | relay.wormhole |
192.0.2.1 |
Proxied |
| A | *.wormhole |
192.0.2.1 |
Proxied |
192.0.2.1 is an originless documentation placeholder; the Worker Route handles requests. Then run:
wormhole relay deploy cloudflare --domain wormhole.example.com --manual-dnsManual mode skips zone/DNS APIs but still deploys the Worker, routes, migration, secrets, health
check, and onboarding. Use VPS wormholed for QUIC, raw TCP, arbitrary upgrades, WebSocket
extensions/raw bytes, or offline webhook buffering. See the
Cloudflare Worker guide.
After pointing apex and wildcard DNS at Debian/Ubuntu:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/nikuscs/wormhole/releases/latest/download/wormholed-bootstrap.sh \
| sudo shNoninteractive Cloudflare DNS-01 install:
sudo chmod 600 /root/cloudflare.token
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/nikuscs/wormhole/releases/latest/download/wormholed-bootstrap.sh \
| sudo sh -s -- --domain tun.example.com --email ops@example.com \
--cloudflare-token-file /root/cloudflare.token -y-y accepts the plan. UFW and overwrite still require --configure-ufw and --force. Secrets are
never accepted as arguments. Without a client key, bootstrap prints one single-use invite. See the
server deployment guide.
- One command for Wormhole, Tailscale, Cloudflare, or all three
- HTTP(S), raw TCP, stable worktree URLs, and declarative projects
- Process supervision, inspection/replay, retries, and durable webhook buffering
- Self-hosted VPS and Cloudflare Worker relays
- Deterministic JSON and a local Unix-socket API; no web UI required
| Capability | Wormhole | ngrok | Others | portless |
|---|---|---|---|---|
| Self-hosted public relay | Yes | Enterprise offering | Varies | No |
| HTTP and raw TCP forwarding | Yes | Yes | Varies | Local HTTP routing |
| One command, multiple providers | Wormhole + Tailscale + Cloudflare | ngrok network | Usually one provider | Local routing + integrations |
| Worktree-scoped services | Yes | No | Varies | Yes |
| Request inspection and replay | Local CLI/API | Hosted inspector | Varies | No |
| Durable offline webhook buffering | Yes | No | Varies | No |
| Deterministic JSON and local APIs | Yes | CLI/API | Varies | CLI |
Alternatives: LocalCan for native macOS and .local domains,
Cloudflare Tunnel,
ngrok,
Tailscale Funnel,
zrok, portless, and
LocalTunnel.
With the daemon running, open http://127.0.0.1:52731/docs. Management
uses the daemon bearer token. Remote onboarding is available through GET /v1/remotes,
POST /v1/remotes, and DELETE /v1/remotes/{name}; invite values are never returned or persisted.
- Optional macOS menu-bar companion
- Server deployment
- Cloudflare Worker deployment
- Local releases
- Wire protocol
Wormhole is MIT licensed. Release archives and container images include the license and generated third-party notices. Cloudflare and Tailscale tools are separate products subject to their own licenses and terms; Wormhole is not affiliated with either company.
Requires Rust 1.97+.
make lint
make test
make e2eAfter a clean push, make signoff runs formatting, lint, size, build, tests, E2E, shell/bootstrap,
and dependency policy before recording gh-signoff. Cloud CI runs only with a PR's run-ci label
or workflow_dispatch; macOS and coverage remain manual.
make coverage writes workspace coverage. make coverage-e2e adds ignored local-socket E2E flows
and writes HTML to target/llvm-cov/html.
Worm icon from Twemoji, licensed under CC BY 4.0.
Warning
This project was developed with AI assistance. Review it before production or security-sensitive use.