A self-hosted ngrok alternative. Expose local servers to the internet through a relay server you control.
Browser → Relay Server → WebSocket → CLI Client → localhost
- HTTP tunneling with path-based or subdomain routing
- Multi-tunnel isolation — run multiple apps simultaneously without cross-contamination
- Single tunnel mode — lock the relay to one tunnel; root domain proxies directly with no path prefix
- Cloudflare Worker subdomain routing — free wildcard subdomains even on Railway's free tier
- Named tunnels — stable human-readable URLs via
--name - Vite / SPA support — automatic WebSocket and fetch rewrite for HMR and API calls
- TCP tunneling — for databases, SSH, etc. (per-tunnel dynamic ports)
- UDP tunneling — for DNS, game servers, etc. (per-tunnel dynamic ports)
- Request inspector — local web UI at
:4040 - Auto-reconnection with tunnel ID preservation
- Admin dashboard at
/dashboardon the relay - Prometheus metrics at
/metrics - Cross-platform — Linux, macOS, Windows (amd64/arm64)
- Daemon mode —
--silentbackground mode,tunnel daemon monitor|status|stop, size-rotated logs (release binaries andmake installship it; opt out withmake install-no-daemon) - Diagnostics —
tunnel doctorwalks setup,tunnel listenumerates active tunnels,tunnel config showmasks tokens - Signed releases — checksums + cosign keyless signing (Sigstore Rekor tlog)
With brokit (recommended if you use multiple tools from this org — handles install + update + uninstall):
brokit install tunnel # install latest release
brokit update tunnel # upgrade to latest
brokit list # see installed tools and versions
brokit remove tunnel # uninstallbrokit reads the GitHub releases for this repo, verifies sha256, and drops the binary into /usr/local/bin. Daemon mode and man pages are included.
From release binary (Linux/macOS, single-tool install):
curl -sSL https://raw.githubusercontent.com/anivaryam/tunnel/main/install.sh | shThe installer fetches the matching release archive, verifies its sha256 against checksums.txt, extracts the binary into /usr/local/bin/tunnel, and unpacks man pages alongside it.
From source:
git clone https://github.com/anivaryam/tunnel.git
cd tunnel
make install # builds with -tags daemon (default; includes --silent, daemon monitor/stop)
make install-no-daemon # smaller binary without daemon modeWith Go (skips daemon mode and man pages — release binary recommended for end-user installs):
go install github.com/anivaryam/tunnel/cmd/tunnel@latestEach release ships checksums.txt, checksums.txt.sig, and checksums.txt.pem (cosign keyless via GitHub Actions OIDC). Verify the bundle before extracting:
cosign verify-blob \
--certificate checksums.txt.pem \
--signature checksums.txt.sig \
--certificate-identity-regexp 'https://github.com/anivaryam/tunnel/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txtThen sha256sum -c checksums.txt against the downloaded archives.
Release archives bundle man/tunnel*.1. After extracting:
install -m 644 man/tunnel*.1 ~/.local/share/man/man1/
man tunnel# Configure once
tunnel config set-server https://relay.example.com
tunnel config set-token your-secret-token
tunnel config show # confirm (token is masked)
# Sanity-check the setup before exposing anything
tunnel doctor
# Expose a local HTTP server
tunnel http 3000
# → https://relay.example.com/t/<random-16-char-id>/
# Expose with a stable name (and subdomain when BASE_DOMAIN is set on the relay)
tunnel http 3000 --name myapp
# → https://relay.example.com/t/myapp/ (path-based)
# → https://myapp.relay.example.com/ (subdomain, if BASE_DOMAIN configured)
# Expose with request inspector
tunnel http 3000 --inspect # http://127.0.0.1:4040
# Expose TCP / UDP services
tunnel tcp 5432 # Postgres, SSH, etc.
tunnel udp 53 # DNS, game servers, etc.
# Run in background (release binary / `make install`)
tunnel http 3000 --name api --silent
tunnel daemon status --name api # human snapshot
tunnel daemon status --name api --json # machine-readable
tunnel daemon monitor --name api # interactive TUI
tunnel daemon stop --name api # graceful shutdown
# Enumerate live tunnels (queries the relay's dashboard API)
tunnel list # table
tunnel list --json # script-friendlytunnel monitor|status|stop (top-level, without daemon) still work — both forms are supported.
For framework-specific setup tips (CORS, cookies, WebSockets, asset paths, redirects), see Optimizing Local Projects for tunnel.
tunnel http <port> [flags] Create an HTTP tunnel
tunnel tcp <port> [flags] Create a TCP tunnel
tunnel udp <port> [flags] Create a UDP tunnel
Configuration:
tunnel config set-server <url> Set relay server URL (validates scheme + host)
tunnel config set-token <tok> Set auth token (rejects empty)
tunnel config show Print current config (token masked)
Diagnostic & introspection:
tunnel doctor Walk config, perms, URL parse, relay reachability, token
tunnel list [--json] Enumerate active tunnels via the relay's dashboard API
Daemon mode (release binary or `make install`):
-s, --silent Re-exec detached and exit. Parent waits up to 3s
for the child to reach state=connected and warns
if it doesn't (bad URL, bad token, network down).
--log-file string Daemon log path (default: $XDG_RUNTIME_DIR/tunnel/tunnel-<hash>.log)
--max-log-size int Rotate log when it exceeds N bytes (0 = no rotation)
tunnel daemon stop [--name X | --port N --mode http|tcp|udp] Graceful SIGTERM
tunnel daemon status [--name X | --port N --mode http|tcp|udp] [--json] Snapshot
tunnel daemon monitor [--name X | --port N --mode http|tcp|udp] Interactive TUI
(tunnel stop / status / monitor remain registered as top-level aliases.)
Per-tunnel flags:
--server string Relay server URL (overrides config)
--token string Auth token (overrides config)
--name string Request a stable tunnel name (e.g. 'myapp')
--inspect Enable web inspector at 127.0.0.1:4040 (HTTP only).
If 4040 is in use, inspector is silently skipped.
Global flags:
-q, --quiet Suppress display output
--debug Verbose debug logging
--log-json Emit logs as JSON (slog-based)
Colors honour NO_COLOR, CLICOLOR=0, CLICOLOR_FORCE=1, and TERM=dumb.
Targeting daemons: when --name was used at start, address it with --name. When
no name was used, address it with --port + --mode (defaults to http).
config file: /home/you/.tunnel/config.yml
✓ permissions OK
server url: https://relay.example.com
✓ scheme "https" OK
✓ host "relay.example.com"
relay reachability: GET https://relay.example.com/health
✓ relay responded 200 OK
auth token: abcd***
✓ token present
If everything above is OK, try: tunnel http <port>
Each tunnel is fully isolated — open as many as you need simultaneously. The URL form depends on whether the relay has BASE_DOMAIN set:
# subdomain mode (BASE_DOMAIN=relay.example.com on relay)
tunnel http 3000 --name api # → https://api.relay.example.com/
tunnel http 5173 --name frontend # → https://frontend.relay.example.com/
tunnel tcp 5432 --name db # → db.relay.example.com:<dynamic-port>
# path-based mode (no BASE_DOMAIN)
tunnel http 3000 --name api # → https://relay.example.com/t/api/
tunnel http 5173 --name frontend # → https://relay.example.com/t/frontend/Default per-token cap is 50 simultaneous tunnels (TUNNEL_MAX_TUNNELS_PER_TOKEN); per-IP cap 100. Hit the cap and the relay returns 429.
With subdomain routing each app gets its own browser origin, so cookies, WebSocket connections, and cached resources never mix between tunnels. See Subdomain Routing below.
| Variable | Required | Description |
|---|---|---|
TUNNEL_AUTH_TOKENS |
Yes | Comma-separated list of valid auth tokens |
PORT |
No | HTTP server port (default 8080) |
PUBLIC_HOST |
No | Hostname for TCP/UDP addresses sent to clients. Derived from HTTP Host header if unset |
BASE_DOMAIN |
No | Enables subdomain routing. Set to your relay domain (e.g. tunnel.example.com). Tunnels become {id}.tunnel.example.com |
CF_WORKER_ENABLED |
No | Set to true to enable Cloudflare Worker-based subdomain routing. When false (default), the relay uses direct DNS subdomain routing |
TUNNEL_WORKER_SECRET |
No | Shared secret for Cloudflare Worker integration. Only verified when CF_WORKER_ENABLED=true |
SINGLE_TUNNEL_MODE |
No | Set to true to allow only one tunnel at a time. Root domain (tunnel.example.com) proxies directly to it — no path prefix needed. Second tunnel connections are rejected. |
TUNNEL_RESPONSE_TIMEOUT |
No | Max time the relay waits for a CLI response before returning 504. Go duration syntax (e.g. 5m, 120s). Default 5m |
TUNNEL_MAX_TUNNELS_PER_TOKEN |
No | Cap on simultaneous tunnels per auth token. Default 50. Set to 0 to disable |
TUNNEL_MAX_TUNNELS_PER_IP |
No | Cap on simultaneous tunnels per remote IP. Default 100. Set to 0 to disable |
TUNNEL_WS_ORIGINS |
No | Comma-separated Origin allowlist for /ws/connect. Empty (default) skips the check (CLI clients send no Origin header). Set only when expecting browser-only clients, e.g. TUNNEL_WS_ORIGINS=https://my-app.com,https://other.com |
TUNNEL_TRUST_PROXY |
No | Default false strips inbound X-Forwarded-* and rewrites from observed values. Set true ONLY when the binary sits behind a trusted TLS-terminating LB (Railway, Cloudflare, fly.io); the local app then sees the original client IP and https scheme. Setting true on the public edge would let any peer lie about their source IP |
TUNNEL_HERO_LOTTIE_URL |
No | Optional Lottie/.lottie asset URL rendered as the cyberpunk hero panel on the public landing page. Loaded client-side via dotlottie-player. When unset the hero panel is omitted entirely. Example: https://assets-v2.lottiefiles.com/a/<id>/<file>.lottie |
- Fork this repo and connect it to Railway
- Railway auto-detects the Dockerfile
- Set environment variables (see table above)
- Generate a domain under Networking settings (e.g.
tunnel.example.com)
Note: TCP/UDP tunnels with per-tunnel dynamic ports work best on a self-hosted VPS where direct port access is available.
docker build -t tunnel-server .
docker run -p 8080:8080 -e TUNNEL_AUTH_TOKENS=my-token tunnel-servermake build-server
TUNNEL_AUTH_TOKENS=my-token ./bin/tunnel-serverSubdomain routing gives each tunnel its own URL (api.tunnel.example.com) instead of a path prefix (tunnel.example.com/t/api/). This is the recommended mode when running multiple apps — each tunnel gets a separate browser origin so there is no cross-contamination between sessions.
There are two ways to enable it depending on your hosting plan.
If your server is reachable directly or you have a Railway plan that accepts a wildcard domain:
- Add a
*.tunnel.example.comDNS record pointing to your server - Set
BASE_DOMAIN=tunnel.example.comon the relay
Railway's free plan allows only one custom domain per service, so *.tunnel.example.com can't be added directly. A free Cloudflare Worker acts as the wildcard entry point and forwards requests to Railway.
Architecture:
Browser → *.tunnel.example.com → Cloudflare Worker → tunnel.example.com (Railway)
CLI → tunnel.example.com (Railway, direct)
Setup:
-
Your domain's DNS must be managed by Cloudflare (add it as a zone — free)
-
Add a wildcard DNS record in Cloudflare:
- Type:
CNAME - Name:
* - Target:
tunnel.example.com - Proxy: enabled (orange cloud)
- Type:
-
Create a Cloudflare Worker and paste the contents of
cloudflare-worker/index.js -
Add a Worker route:
*.tunnel.example.com/*→ your Worker -
Set Worker environment variables:
BASE_DOMAIN = tunnel.example.com RAILWAY_URL = https://tunnel.example.com WORKER_SECRET = <generate with: openssl rand -hex 32> -
Set Railway environment variables:
BASE_DOMAIN = tunnel.example.com TUNNEL_WORKER_SECRET = <same value as WORKER_SECRET above> CF_WORKER_ENABLED = true
That's it. Named tunnels now resolve to {name}.tunnel.example.com:
tunnel http 3000 --name api # → https://api.tunnel.example.com
tunnel http 5173 --name frontend # → https://frontend.tunnel.example.comRequest a stable, human-readable ID instead of a random 16-char base62 one:
tunnel http 3000 --name eserviceName rules: 1–32 characters, lowercase alphanumeric or hyphen, no leading/trailing hyphen.
Reserved names are rejected with policy violation: invalid name (close code 1008). The relay reserves server-control paths (dashboard, metrics, health, ws, t, api, admin) plus phishing-surface and infrastructure-style subdomains (mail, login, auth, account, billing, secure, support, static, cdn, assets, app, docs, blog, status, staging, prod, …). The full list lives in internal/server/names.go.
Names are first-come-first-served. When a named tunnel disconnects the name is held for 60 seconds so the same token can reconnect and reclaim it (matches the behavior for random IDs).
When you only need one tunnel and want to share a clean root URL with no path prefix or subdomain, set SINGLE_TUNNEL_MODE=true on the relay.
# Relay started with SINGLE_TUNNEL_MODE=true
tunnel http 3000 --name myapp
# → https://tunnel.example.com/ (root, no /t/myapp/ prefix)Behavior:
- Root domain (
tunnel.example.com/) proxies directly to the active tunnel - If no tunnel is connected, visiting the root returns a "No tunnel connected" status page
- A second tunnel attempting to connect is rejected with an error:
single tunnel mode: a tunnel is already connected
Multi-tunnel mode (default): Visiting the root domain with no active tunnel context shows a live list of all connected tunnels and their URLs.
Release binaries and make install ship daemon support; only go install and make install-no-daemon skip it.
tunnel http 3000 --name api --silent
# tunnel daemonized (pid=12345, hash=abcd1234)
# logs: /run/user/1000/tunnel/tunnel-abcd1234.log
# socket: /run/user/1000/tunnel/tunnel-abcd1234.sock
# monitor: tunnel monitor --name api
tunnel daemon status --name api # human-readable snapshot
tunnel daemon status --name api --json # machine-readable
tunnel daemon monitor --name api # interactive TUI: requests, streams, traffic
tunnel daemon stop --name api # graceful SIGTERMThe parent process waits up to 3 seconds for the child to reach state=connected before exiting. If it can't (bad URL, bad token, network down) you see a one-line warning:
warning: daemon did not reach 'connected' state within 3s — check the log file or run `tunnel status`
File layout (per-user, mode 0700):
- Linux:
$XDG_RUNTIME_DIR/tunnel/(falls back to$TMPDIR/tunnel-<uid>/) - macOS:
$TMPDIR/tunnel-<uid>/ - Windows:
%LOCALAPPDATA%\tunnel\for PID/log;\\.\pipe\tunnel-<hash>for IPC
Each daemon owns three files there: tunnel-<hash>.sock (mode 0600), .pid (0600), .log (0600). Hash derives from --name or mode + port.
When no --name was used at start, target the daemon by --port <N> --mode http|tcp|udp. Pass --log-file to override the log path and --max-log-size <bytes> to enable size-based rotation (3-file ring).
| Endpoint | Auth | Description |
|---|---|---|
GET /health |
none | Liveness check (always 200 OK) |
GET /ws/connect |
Bearer token | WebSocket tunnel endpoint (used by the CLI) |
GET /t/{id}/... |
none | HTTP proxy to tunnel (path-based routing) |
{id}.{BASE_DOMAIN}/... |
none | HTTP proxy to tunnel (subdomain routing) |
GET /dashboard |
cookie / Bearer / ?token= |
Admin dashboard. First load with ?token=X sets an HttpOnly cookie and redirects; subsequent loads require no token in URL |
GET /dashboard/api |
cookie / Bearer | JSON list of active tunnels (tunnel list calls this) |
GET /metrics |
Bearer / ?token= |
Prometheus metrics. Open when no TUNNEL_AUTH_TOKENS is configured |
- The CLI client opens a WebSocket to
relay/ws/connect, authenticating viaAuthorization: Bearer <token>. - The relay validates the token (constant-time compare), checks per-token / per-IP caps, and assigns a tunnel ID (random 16-char base62, or the requested
--name) — sent back as the first message. - The CLI prints the public URL and starts listening for messages from the relay.
Browser GET https://tunnel.example.com/t/myapp/api/users
→ relay ProxyHandler
→ relay sends TypeHTTPRequest envelope to CLI client over WebSocket
→ CLI client makes GET http://localhost:3000/api/users
→ CLI sends TypeHTTPResponse back over WebSocket
→ relay writes response to browser
For large uploads the request body is streamed as TypeHTTPRequestChunk messages. For SSE and streaming responses the relay sends TypeHTTPStreamChunk messages until TypeHTTPStreamEnd or the browser disconnects.
When BASE_DOMAIN, TUNNEL_WORKER_SECRET, and CF_WORKER_ENABLED=true are configured, the Cloudflare Worker handles *.tunnel.example.com:
- Worker extracts the subdomain (e.g.
myappfrommyapp.tunnel.example.com) - Worker adds
X-Forwarded-Tunnel: myappandX-Worker-Secret: <secret>headers - Worker proxies the request to Railway (
tunnel.example.com) - Relay's
subdomainMiddlewareverifies the secret with constant-time comparison and routes to tunnelmyapp
The CLI connects directly to tunnel.example.com — it never goes through the Worker.
When proxying HTML over path-based routing, the relay injects a small script into the <head> that:
- Rewrites
window.WebSocket,window.fetch,XMLHttpRequest.open, andwindow.EventSourceto route same-origin and localhost URLs through/t/{id}/ - Calls
history.replaceStateto strip the/t/{id}/prefix so SPA routers (React Router, Vue Router) see clean paths like/dashboardinstead of/t/myapp/dashboard
With subdomain routing the script is not injected — the tunnel already has its own origin, so no URL rewriting is needed.
The CLI strips the Origin header before dialing the local WebSocket so dev servers that enforce same-origin checks (Vite 5.1+, Next.js Turbopack) do not reject the proxied upgrade.
TCP and UDP tunnels get a dynamically allocated port on the relay. Connections to that port are relayed as multiplexed streams over the same WebSocket, each with a unique stream ID for concurrent handling.
Every message is two WebSocket frames:
- A JSON text frame (the envelope — message type, request/stream ID, metadata)
- A binary frame (the body — request/response body, chunk data, or empty)
A mutex-protected writer ensures both frames are written atomically, which is required for correct concurrent operation.
If the WebSocket disconnects, the CLI reconnects with exponential backoff (1 s → 30 s max, ±25% jitter). It sends ?tunnel_id=<previous-id> so the relay can restore the same public URL instead of assigning a new one. The relay holds the tunnel ID in a reservation for 60 seconds after disconnect.
Config is stored at ~/.tunnel/config.yml (mode 0600, parent dir 0700):
server_url: https://relay.example.com
auth_token: your-secret-tokentunnel config show prints the resolved config with the token masked. Permissions are tightened on every save, so an upgrade from a pre-1.7 binary that wrote 0644 is corrected the next time you run tunnel config set-server or set-token.
tunnel doctoris the first stop. It validates the config file, checks file permissions, parses the server URL, hits/health, and reports each step with a colored ✓/!/✗ marker.- Connection refused / DNS errors are translated to one-line actionable messages instead of four-level error wrapping.
tunnel http 3000exits withrelay URL is invalid— the URL fails to parse. Re-runtunnel config set-server <url>(it now validates scheme and host).- 429 Too Many Requests on connect — per-token or per-IP cap reached. Bump
TUNNEL_MAX_TUNNELS_PER_TOKEN/TUNNEL_MAX_TUNNELS_PER_IPor set them to0. - HTTP requests through the tunnel get
connection refused— local app is bound to::1only. Either bind it to0.0.0.0/127.0.0.1, or check the dev server's bind config. The CLI dials127.0.0.1:<port>explicitly. --inspectshows "inspector disabled" — port 4040 is in use; the tunnel runs without the inspector. Stop whatever owns 4040 (lsof -i :4040) or skip--inspect.--silentwarns about not reachingconnected— child daemon is running but cannot reach the relay. Check the log file (logs:line printed at start) or runtunnel daemon status --name <X>.--debugturns on verbose logging;--log-jsonroutes the same logs through a structured JSON handler suitable for log aggregators.
- Inspector port is hard-coded to
127.0.0.1:4040(not configurable). - Local apps must bind to
127.0.0.1(or be dual-stack); apps that bind only to::1are unreachable. - The relay does not terminate TLS itself — deploy behind a TLS-terminating load balancer (Railway, Cloudflare, fly.io) and set
TUNNEL_TRUST_PROXY=trueif upstreamX-Forwarded-*headers should be honoured. - Single dashboard URL per relay (no per-token namespacing).
- Daemon mode is Linux/macOS first-class; Windows daemon support uses named pipes and works but has fewer integration tests.
Operational changes worth knowing about (no API breaks for end users):
- Daemon files moved.
$TMPDIR/tunnel-<hash>.{sock,pid,log}→$XDG_RUNTIME_DIR/tunnel/tunnel-<hash>.{sock,pid,log}(mode 0600 inside a 0700 dir). Stop any pre-1.7 daemon before upgrading; newtunnel daemon stop|status|monitorcommands look only at the new path. TUNNEL_TRUST_PROXYdefaults to false. If you deploy on Railway / Cloudflare / fly.io and want the original client IP andhttpsscheme to reach the local app, setTUNNEL_TRUST_PROXY=trueon the relay.- Per-token / per-IP caps default on. 50 tunnels per token, 100 per IP. Heavy users can raise via
TUNNEL_MAX_TUNNELS_PER_TOKEN/TUNNEL_MAX_TUNNELS_PER_IPor set to0to disable. - Tunnel ID length 8 → 16. No API contract on length; bookmarked URLs that hard-coded an old random ID won't survive (named tunnels with
--nameare unaffected). - Config file mode. Tightened to
0600on every save; existing0644files are corrected on nexttunnel config set-....
make test # run default-tag tests
make test-daemon # run with -tags daemon
make test-all # both
make build-server # build relay binary → bin/tunnel-server
make build-client # build CLI binary (no daemon)
make build-client-daemon# build CLI binary with daemon supporttunnel works on Windows. ANSI color output is supported via Windows Terminal or PowerShell.
# Recommended: brokit (includes daemon mode)
brokit install tunnel
# Or download release binary directly:
# https://github.com/anivaryam/tunnel/releases
# Or via Go (no daemon mode):
go install github.com/anivaryam/tunnel/cmd/tunnel@latestMIT
