Self-hosted ngrok alternative on Tailscale —
share all your local dev servers through one stable *.ts.net URL, routed by project name.
A native menu-bar app — start/stop the proxy, watch your dev servers, and share
them, no terminal needed.
| Platform | Direct download |
|---|---|
| 🍎 macOS · Apple Silicon & Intel | Tailscale-Proxy-macOS.dmg |
| 🪟 Windows · 64-bit | Tailscale-Proxy-Windows.zip |
| 🐧 Linux · 64-bit | Tailscale-Proxy-Linux.tar.gz |
→ All releases · setup, screenshots & docs
npx tailscale-proxy # discovers your dev servers + shares them via Tailscale
brew install meabed/tap/tsp && tsp # or install the binaryFirst time? npx tailscale-proxy doctor checks your setup — see Requirements.
An open-source, self-hosted ngrok alternative built on Tailscale. Discover your local dev servers by port, and expose them through a single Tailscale entry — privately (Serve, tailnet-only) or publicly (Funnel) — routed by project name.
vs. ngrok: your own stable
*.ts.netURL over your Tailscale tunnel — no third-party relay, no random per-session URLs, no rate limits/paywalls. One hostname for many dev servers, discovered automatically (nongrok http 3000per port).
No per-app wiring: just run your servers (node, bun, deno, python, php, ruby, go, java, …) and tsp finds the ones listening in a port range, derives a path slug
from each project's folder, and routes to them under one hostname:
https://<node>.ts.net/<project>/foo
└────┬────┘
tsp strips the segment, forwards → 127.0.0.1:<port>/foo
It re-scans every few seconds (so servers that come and go are picked up), keeps a service around for a few scans before de-registering (no flapping on restarts), and streams SSE / proxies WebSocket upgrades. Zero runtime dependencies — one small Go binary.
# 0. One-time: install Tailscale, sign in, enable Funnel (see Requirements below)
tailscale up
# 1. Start any dev servers (each in its own project folder → that's the URL path)
cd ~/sites/portfolio && npx serve -l 3000 # static site (node)
cd ~/apps/web && npx next dev -p 4000 # Next.js app (node)
cd ~/apps/api && bun run dev # API (bun, e.g. :4100)
# 2. Check your environment, then share them all through one Tailscale URL
npx tailscale-proxy doctor
npx tailscale-proxy # "start" is the default commandServices:
https://bigfoot.tail-scale.ts.net/portfolio/ → 127.0.0.1:3000
https://bigfoot.tail-scale.ts.net/web/ → 127.0.0.1:4000
https://bigfoot.tail-scale.ts.net/api/ → 127.0.0.1:4100
Open any of those from anywhere (Funnel) or from your tailnet (--private Serve).
Ctrl-C resets the Serve/Funnel entry on exit.
# Save preferences once, then a bare `tsp` uses them:
npx tailscale-proxy configure --ports 3000-9000 --runtimes node,bun,python
npx tailscale-proxyNon-JS servers (
python3 -m http.server,php -S,ruby -run -e httpd) are included with--allor--runtimes node,python,….
👉 Full setup + lots of real examples: docs/EXAMPLES.md
| Method | Command |
|---|---|
| npx (no install) | npx tailscale-proxy <command> |
| npm (global) | npm i -g tailscale-proxy |
| Homebrew | brew install meabed/tap/tsp |
Supported: macOS, Linux, Windows, WSL (amd64 + arm64).
(go install github.com/meabed/tailscale-proxy@latest also works if you have Go.)
Update later with tsp update — it self-updates a standalone binary, or prints
brew upgrade tsp / npm i -g tailscale-proxy@latest for managed installs.
The menu-bar app (download links above) runs the same
engine as the CLI and shares the same ~/.tailscale-proxy/config.json. Each service
shows its runtime, port, cpu · memory · uptime, and a ⋯ menu (open local/proxy
URL, open folder, copy info, kill). Settings window for the full config + start-at-login.
Docs, screenshots & install steps →
Build from source: cd desktop && go build -o tsp-app . && ./tsp-app
(see desktop/README.md).
tsp [flags] Default: run "start" with your saved config
tsp start Discover services, run the proxy, and expose it
tsp status Serve/Funnel status + the current service map
tsp list Discovered services (slug → runtime, port, project, URL)
tsp reset Remove the Serve/Funnel entry and exit
tsp doctor Check tailscale, exposure readiness, and discovery
tsp configure Save defaults to ~/.tailscale-proxy/config.json
tsp update Update to the latest release
Run tsp start --help for all flags. Global: -h/--help, -v/--version.
| Flag | Default | Meaning |
|---|---|---|
--ports <lo-hi|port> |
3000-5000 |
Port range or a single port to scan |
--all |
off | Include all listeners, not just web runtimes |
--runtimes <list> |
all known | Restrict to specific runtimes, e.g. node,bun,python |
--private |
off | Expose privately via Tailscale Serve (default: Funnel) |
--bind <addr> |
127.0.0.1 |
Proxy listen address. Use 0.0.0.0 to reach the proxy from Docker containers / the LAN without MagicDNS |
--port <n> |
8443 |
Local proxy HTTP port |
--interval <sec> |
20 |
Re-scan period |
--https-port <n> |
443 |
Public/tailnet HTTPS port (Funnel: 443/8443/10000) |
--deregister-cycles <n> |
5 |
Missing scans before a gone service is removed |
--forward-host |
off | Forward the public host to apps via X-Forwarded-Host/Proto. Default presents a local request so apps behave exactly like localhost |
--accept-dns <bool> |
unset | Optionally tailscale set --accept-dns=<true|false> on start. Unset = leave it alone. false lets a tailnet host resolve the public funnel name (persists after exit) |
--bg |
off | Run detached (logs → ./tsp.log) |
--proxy-only |
off | Run the proxy only; print the tailscale command |
--log-requests |
on | Log each proxied request |
--quiet |
off | Disable per-request logging |
On startup tsp prints whether it loaded your config and the effective parameters,
then logs each discovered service and any de-registration:
Using config: /Users/me/.tailscale-proxy/config.json
ports=3000-5000 mode=public (Funnel) proxy=127.0.0.1:8443 https=443
interval=20s runtimes=default (node,bun,deno,python,ruby,php,go,java,…) deregister-after=5 scans log-requests=true
2026/05/31 02:05:48 discovered help-ai-web node :4983 ~/work/help-ai/apps/web
2026/05/31 02:05:49 200 GET /help-ai-web/ → 127.0.0.1:4983 (6ms)
Request logs are colorized by status on a terminal (set NO_COLOR to disable).
tsp configure [flags] writes ~/.tailscale-proxy/config.json (created on first
use). Flags override config at runtime; the file is the source of defaults.
{
"ports": "3000-5000", "all": false, "runtimes": "", "private": false,
"bind": "127.0.0.1", "port": 8443, "interval": 20, "httpsPort": 443,
"logRequests": true, "deregisterCycles": 5, "forwardHost": false, "acceptDns": ""
}- Tailscale, logged in:
tailscale up # opens a browser to sign up / log in - For public exposure (Funnel) — not needed for
--privateServe:- Enable HTTPS certificates: admin console → DNS → MagicDNS + HTTPS (docs).
- Grant the
funnelnode attribute in your tailnet policy file (admin console → Access controls): Funnel docs)
lsofon macOS/Linux (macOS ships it; Linux:apt/dnf install lsof). Windows usesnetstat/tasklist(built in).
Run tsp doctor — it checks all of the above and prints the exact fix link.
Step-by-step with screenshots-worth-of-detail: docs/EXAMPLES.md.
flowchart LR
C["Caller<br/>internet or tailnet"]
C -->|"https://node.ts.net/web/foo"| TS
subgraph machine["your machine"]
direction TB
TS["tailscale funnel | serve<br/>TLS · :443 / 8443 / 10000"]
P["tsp proxy<br/>net/http · :8443"]
SCAN["discovery loop<br/>lsof·ps / netstat·tasklist"]
D1["127.0.0.1:4983<br/>web · bun"]
D2["127.0.0.1:3000<br/>api · node"]
TS -->|plain HTTP| P
P -->|"/web/foo → strip → /foo<br/>Host → localhost:4983"| D1
P --> D2
SCAN -->|"slug → port map"| P
SCAN -.->|"scan every --interval"| D1
SCAN -.-> D2
end
- Every
--intervalseconds,tsplists listening TCP sockets in the range (macOS/Linux vialsof+ps, Windows vianetstat+tasklist), classifies the runtime, and derives a slug from the nearest project-root folder (package.json/.git/…), de-duplicating collisions. - A
net/httpreverse proxy matches the first path segment to a service, strips it, rewritesHost, and forwards to127.0.0.1:<port>(streaming + WebSocket preserved, bounded connection pool). tailscale serve|funnel --bg <proxy-port>exposes the proxy. On exit the entry is reset.
Request routing:
flowchart TD
R["Request /seg/rest?query"] --> M{"first segment<br/>matches a slug?"}
M -->|yes| H["strip segment · Host → localhost:port<br/>set tsp_route cookie"]
M -->|"no (prefix-less asset/HMR)"| CK{"tsp_route<br/>cookie set?"}
CK -->|yes| FF["forward full path to the cookie's backend"]
CK -->|no| NF["404 + list of services"]
H --> UP{"backend up?"}
FF --> UP
UP -->|yes| OK["stream response<br/>(SSE · WebSocket)"]
UP -->|no| E502["502"]
More — including the discovery pipeline and lifecycle sequence diagrams — in docs/HOW-IT-WORKS.md.
tsp doctor first. Common issues (full list in
docs/TROUBLESHOOTING.md):
- Works from my phone but not my Mac — from the host, MagicDNS resolves
<node>.ts.netto your tailnet IP, so requests may not traverse the public Funnel. Test from outside your tailnet (see the doc for how to force it locally). - No services found — start a dev server in range, widen
--ports, or--all. lsofnot found — install it (apt/dnf install lsof).
go test ./... # run the test suite
go vet ./... # static checks
go build -o tsp . # build the binary
goreleaser release --snapshot --clean --skip=publish # full cross-platform buildCI builds, vets, and race-tests on Linux/macOS/Windows and cross-compiles all six release targets on every push. Releases are tag-driven — see docs/RELEASING.md.
MIT © Mohamed Meabed

{ "nodeAttrs": [ { "target": ["autogroup:member"], "attr": ["funnel"] } ] }