ReviewOS

stacks/localtunnels

public
Clone

Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.

main
· 9 branches · 214 commits

Mirrored from stacksjs/localtunnels · syncing is switched off

README.md

Social Card of this repo

npm version GitHub Actions Commitizen friendly

localtunnels

A zero-config local tunnel that's simple, lightweight, and secure.

Features

  • Simple, lightweight local tunnel
  • Security built-in, including HTTPS
  • Binary-safe forwarding (fonts, archives, media & uploads survive byte-for-byte)
  • Standard proxy headers (X-Forwarded-For / -Host / -Proto for your local app)
  • Smart subdomains (APP_NAME-aware, memorable random names, auto-collision handling)
  • Auto DNS resolution (bypasses broken system DNS on macOS .dev TLD)
  • Built-in devtools & Prometheus metrics (per-tunnel request log, /metrics, /status)
  • WireGuard-style VPN mode (private layer-3 mesh, powered by a Zig crypto core)
  • Self-hostable anywhere, with IaC deploys via ts-cloud (AWS & Hetzner today, more providers as ts-cloud grows)
  • CLI & Library

Install

bun install -d localtunnels

Get Started

There are two ways of using this local tunnel: as a library or as a CLI.

Library

Given the npm package is installed:

import { startLocalTunnel } from 'localtunnels'

const client = await startLocalTunnel({
  port: 3000,
  // subdomain: 'myapp', // optional, see Subdomains below
  // verbose: true, // optional
})

console.log(`Tunnel URL: ${client.getTunnelUrl()}`)

// later...
client.disconnect()

Or use the TunnelClient class directly:

import { TunnelClient } from 'localtunnels'

const client = new TunnelClient({
  host: 'localtunnel.dev',
  port: 443,
  secure: true,
  localPort: 3000,
})

client.on('connected', (info) => {
  console.log(`Public URL: ${info.url}`)
})

await client.connect()

CLI

# Expose local port 3000 (default)
localtunnels start

# Expose a specific port
localtunnels start --port 8080

# Request a specific subdomain
localtunnels start --port 3000 --subdomain myapp

# Use a custom tunnel server
localtunnels start --port 3000 --server mytunnel.example.com

# Disable auto DNS resolution
localtunnels start --port 3000 --no-manage-hosts

# Show all requests
localtunnels start --port 3000 --verbose

# TUNNEL_SERVER and TUNNEL_SUBDOMAIN env vars set defaults for the flags
TUNNEL_SERVER=mytunnel.example.com TUNNEL_SUBDOMAIN=myapp localtunnels start --port 3000

Output:

  Connecting to localtunnel.dev...

  Public:     https://swift-fox.localtunnel.dev
  Forwarding: https://swift-fox.localtunnel.dev -> http://localhost:3000

  Press Ctrl+C to stop sharing

Subdomains

localtunnels uses a smart subdomain resolution chain:

  1. Explicit flag: --subdomain myapp or subdomain: 'myapp' in code
  2. APP_NAME env var: automatically slugified (e.g. My Cool App becomes my-cool-app)
  3. Random memorable name: adjective-noun combos like swift-fox, bold-comet, lazy-elk

Collision Handling

If a subdomain is already in use by another client, localtunnels automatically appends an incrementing suffix:

  • myapp is taken -> tries myapp-2
  • myapp-2 is taken -> tries myapp-3

After a few suffix attempts it switches to fresh random names, and it fails with a clear error (rather than retrying forever) if the server keeps rejecting.

This happens transparently — no crashes, no manual intervention needed.

Examples

# Uses APP_NAME env var if set
APP_NAME="My App" localtunnels start --port 3000
# -> https://my-app.localtunnel.dev

# Explicit subdomain
localtunnels start --port 3000 --subdomain demo
# -> https://demo.localtunnel.dev

# Random memorable name (no APP_NAME, no --subdomain)
localtunnels start --port 3000
# -> https://bold-comet.localtunnel.dev

DNS Resolution

On some machines (especially macOS with .dev TLD), the system DNS resolver can't reach localtunnel.dev even though tools like dig and nslookup work fine. localtunnels detects this automatically and resolves the server IP via DNS-over-HTTPS (Cloudflare) or dig @8.8.8.8, then connects directly to the IP.

This is on by default. Disable with --no-manage-hosts or manageHosts: false.

Self-Hosting

The tunnel server runs anywhere Bun runs — a VPS, a homelab box, a container:

localtunnels server --port 8080 --domain mytunnel.example.com

Clients then connect with --server mytunnel.example.com (or set TUNNEL_SERVER).

Infrastructure as Code

Cloud deployments are powered by ts-cloud, and every deploy takes a provider flag — capabilities aren't tied to a vendor. AWS EC2 and Hetzner Cloud work today; more providers land as ts-cloud grows.

  • Tunnel server — one command, with optional wildcard Let's Encrypt TLS (Porkbun DNS-01) served by Bun's native TLS:

    # AWS EC2 (also automates Route53 DNS)
    localtunnels deploy:tunnel --domain mytunnel.example.com --enable-ssl
    
    # Hetzner Cloud (prints the DNS records to create)
    localtunnels deploy:tunnel --provider hetzner --domain mytunnel.example.com --enable-ssl
  • VPN / exit node — fully-automated, end-to-end-verified, running the localtunnels WireGuard stack (see deploy/):

    bun run deploy:vpn                     # Hetzner (default)
    bun run deploy:vpn -- --provider aws   # AWS EC2
    bun run verify:vpn                     # e2e: handshake, tunnel ping, exit routing

Tear down anytime with localtunnels destroy [--provider hetzner] or bun run destroy:vpn.

VPN Mode

Beyond exposing a single port, localtunnels can join machines into a private, encrypted layer-3 network — a self-hosted, WireGuard-style VPN. It implements the actual WireGuard v1 protocol (Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s) in a dependency-free Zig core (libltvpn, consumed from Bun over bun:ffi), with the control plane in TypeScript.

The core is validated for wire-correctness against an independent reference implementation (packages/vpn-core/testvectors/wg_ref.py, itself checked against the RFC 7748 / 8439 / 7693 vectors): the Zig code produces byte-identical handshake messages, transport keys, and the canonical WireGuard InitialChainKey.

# Generate this machine's identity (X25519 keypair, stored 0600)
lt vpn keygen

# Verify the native core (handshake + encryption + replay protection)
lt vpn selftest

# See it move encrypted traffic between two peers over real UDP
lt vpn demo

# Run the peer-discovery coordinator, then a self-configuring mesh demo
lt vpn coordinator
lt vpn mesh-demo

# Bring up a real layer-3 interface bridged to a peer (needs root)
sudo lt vpn up --peer <pubkey> --endpoint <host>:51820

Features: X25519 identities, ChaCha20-Poly1305 transport with RFC-6479 replay protection, session rekeying, cryptokey routing (allowed-ips), a coordinator for zero-config peer discovery and IP assignment, NAT hole punching with an encrypted relay fallback (the coordinator only ever sees ciphertext), and TUN devices on macOS (utun) and Linux (/dev/net/tun).

Build the native library from source (requires Zig):

bun run build:native   # → packages/vpn-core/zig-out/lib/libltvpn.*
bun run test:native    # Zig unit + known-answer + fuzz tests

VPN features degrade gracefully: if the native library isn't present, the HTTP tunnel above is unaffected.

Benchmarks

localtunnels ships with a benchmark suite built on mitata. The suite covers utility functions, connection lifecycle, request throughput, latency distribution, scalability under load, and cross-tool comparisons.

# Run all benchmarks
bun benchmarks/index.ts

# Run individual suites
bun benchmarks/utils.ts          # Utility function microbenchmarks
bun benchmarks/connection.ts     # Connection lifecycle
bun benchmarks/throughput.ts     # Request forwarding throughput
bun benchmarks/latency.ts        # End-to-end latency distribution
bun benchmarks/scalability.ts    # Multi-connection scalability
bun benchmarks/comparison.ts     # Cross-tool comparison

Results

Measured on Apple M3 Pro, bun 1.3.10 (arm64-darwin). Other tools tested: cloudflared 2026.2.0, ngrok 3.36.1, bore-cli 0.6.0, frpc 0.67.0.

localtunnels vs Alternatives — Request Forwarding

Real end-to-end request forwarding through each tool's tunnel. localtunnels runs on localhost, bore routes through bore.pub.

GET / (plain text):

Toolavgvs direct
Direct (no tunnel)35.67 µs1x (baseline)
localtunnels105.97 µs2.97x
bore188.60 ms5,290x

GET /json (10-item JSON array):

Toolavgvs direct
Direct (no tunnel)30.67 µs1x (baseline)
localtunnels109.58 µs3.57x
bore180.11 ms5,872x

POST 1 KB body:

Toolavgvs direct
Direct (no tunnel)29.43 µs1x (baseline)
localtunnels106.89 µs3.63x
bore180.74 ms6,143x

10 Concurrent Requests (GET /json):

Toolavgvs direct
Direct (no tunnel)108.05 µs1x (baseline)
localtunnels592.58 µs5.48x
bore188.46 ms1,744x

localtunnels vs Alternatives — Startup Time

ToolTime to tunnel ready
localtunnels~324 µs
bore195 ms
Cloudflare Tunnels3,969 ms

localtunnels vs Alternatives — Subdomain Generation

ToolStrategyExample Outputavgvs localtunnels
localtunnelsAdjective-nounfast-deer, quick-surf, fond-opal3.00 ns1x
frpCounter prefixtunnel-1, tunnel-2, tunnel-325.66 ns8.55x slower
Cloudflare TunnelsUUID prefixa7ed76b1, ee76358d, d25abca342.69 ns14.23x slower
ExposeUUID sluga432cef06efa, 15b07c93bc2796.60 ns32.20x slower
boreShort hexdf28e3, 1cb723, 06189e191.94 ns63.98x slower
ngrokRandom hexc2a8b92e, 5c219911, 65a2aba4279.09 ns93.03x slower

localtunnels vs Alternatives — ID Generation

ToolStrategyavgvs fastest
frpCounter-based22.19 ns1x
ngrok / Cloudflare Tunnelscrypto.randomUUID()30.94 ns1.39x
localtunnelscrypto.randomUUID().substring()42.42 ns1.91x
borecrypto.getRandomValues358.60 ns16.16x

localtunnels vs Alternatives — Protocol Overhead

localtunnels uses WebSocket + JSON. bore and frp use binary protocols. This measures per-message encode/decode cost.

ToolOperationavgvs fastest
localtunnelsJSON serialize123.05 ns1x
bore / frpBinary header encode159.04 ns1.29x
bore / frpBinary header decode176.33 ns1.43x
localtunnelsJSON parse434.87 ns3.53x

localtunnels vs Alternatives — State Machine

ToolStrategyavgvs fastest
frp / bore(Go-style)Enum-based2.20 ns1x
localtunnelsString-based2.35 ns1.07x
ngrok / ExposeObject-based3.54 ns1.61x

Throughput (GET, direct vs tunnel)

PayloadDirectlocaltunnelsOverhead
20 B33 µs102 µs3.1x
1 KB30 µs116 µs3.8x
64 KB47 µs350 µs7.5x
512 KB144 µs2.08 ms14.4x
1 MB234 µs4.16 ms17.8x

Latency

Scenarioavg
Instant response (pure overhead)182 µs
JSON API (10-item array)210 µs
With 10 ms backend10.44 ms (1.05x over direct)
With 50 ms backend50.51 ms (1.01x over direct)

Scalability

Active TunnelsRequest Latency (avg)
1235 µs
10237 µs
50234 µs

Connection Lifecycle

Operationavg
Server start + stop325 µs
Client connect + register + disconnect296 µs
5 clients sequential1.61 ms
5 clients concurrent921 µs

The cross-tool comparison auto-detects installed tunneling tools (cloudflared, ngrok, bore, frpc, expose) and includes them in results. See the benchmark documentation for full results, suite descriptions, and methodology.

Testing

bun test

Changelog

Please see our releases page for more information on what has changed recently.

Contributing

Please review the Contributing Guide for details.

Community

For help, discussion about best practices, or any other conversation that would benefit from being searchable:

Discussions on GitHub

For casual chit-chat with others using this package:

Join the Stacks Discord Server

Postcardware

“Software that is free, but hopes for a postcard.” We love receiving postcards from around the world showing where localtunnels is being used! We showcase them on our website too.

Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎

Sponsors

We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.

Credits

License

The MIT License (MIT). Please see LICENSE for more information.

Made with 💙

[codecov-href]: https://codecov.io/gh/stacksjs/localtunnels -->