A zero-config local tunnel that's simple, lightweight, and secure.
- 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/-Protofor your local app) - Smart subdomains (APP_NAME-aware, memorable random names, auto-collision handling)
- Auto DNS resolution (bypasses broken system DNS on macOS
.devTLD) - 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
bun install -d localtunnelsThere are two ways of using this local tunnel: as a library or as a CLI.
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()# 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 3000Output:
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
localtunnels uses a smart subdomain resolution chain:
- Explicit flag:
--subdomain myapporsubdomain: 'myapp'in code APP_NAMEenv var: automatically slugified (e.g.My Cool Appbecomesmy-cool-app)- Random memorable name: adjective-noun combos like
swift-fox,bold-comet,lazy-elk
If a subdomain is already in use by another client, localtunnels automatically appends an incrementing suffix:
myappis taken -> triesmyapp-2myapp-2is taken -> triesmyapp-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.
# 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.devOn 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.
The tunnel server runs anywhere Bun runs — a VPS, a homelab box, a container:
localtunnels server --port 8080 --domain mytunnel.example.comClients then connect with --server mytunnel.example.com (or set TUNNEL_SERVER).
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.
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>:51820Features: 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 testsVPN features degrade gracefully: if the native library isn't present, the HTTP tunnel above is unaffected.
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 comparisonMeasured 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.
Real end-to-end request forwarding through each tool's tunnel. localtunnels runs on localhost, bore routes through bore.pub.
GET / (plain text):
| Tool | avg | vs direct |
|---|---|---|
| Direct (no tunnel) | 35.67 µs | 1x (baseline) |
| localtunnels | 105.97 µs | 2.97x |
| bore | 188.60 ms | 5,290x |
GET /json (10-item JSON array):
| Tool | avg | vs direct |
|---|---|---|
| Direct (no tunnel) | 30.67 µs | 1x (baseline) |
| localtunnels | 109.58 µs | 3.57x |
| bore | 180.11 ms | 5,872x |
POST 1 KB body:
| Tool | avg | vs direct |
|---|---|---|
| Direct (no tunnel) | 29.43 µs | 1x (baseline) |
| localtunnels | 106.89 µs | 3.63x |
| bore | 180.74 ms | 6,143x |
10 Concurrent Requests (GET /json):
| Tool | avg | vs direct |
|---|---|---|
| Direct (no tunnel) | 108.05 µs | 1x (baseline) |
| localtunnels | 592.58 µs | 5.48x |
| bore | 188.46 ms | 1,744x |
| Tool | Time to tunnel ready |
|---|---|
| localtunnels | ~324 µs |
| bore | 195 ms |
| Cloudflare Tunnels | 3,969 ms |
| Tool | Strategy | Example Output | avg | vs localtunnels |
|---|---|---|---|---|
| localtunnels | Adjective-noun | fast-deer, quick-surf, fond-opal |
3.00 ns | 1x |
| frp | Counter prefix | tunnel-1, tunnel-2, tunnel-3 |
25.66 ns | 8.55x slower |
| Cloudflare Tunnels | UUID prefix | a7ed76b1, ee76358d, d25abca3 |
42.69 ns | 14.23x slower |
| Expose | UUID slug | a432cef06efa, 15b07c93bc27 |
96.60 ns | 32.20x slower |
| bore | Short hex | df28e3, 1cb723, 06189e |
191.94 ns | 63.98x slower |
| ngrok | Random hex | c2a8b92e, 5c219911, 65a2aba4 |
279.09 ns | 93.03x slower |
| Tool | Strategy | avg | vs fastest |
|---|---|---|---|
| frp | Counter-based | 22.19 ns | 1x |
| ngrok / Cloudflare Tunnels | crypto.randomUUID() |
30.94 ns | 1.39x |
| localtunnels | crypto.randomUUID().substring() |
42.42 ns | 1.91x |
| bore | crypto.getRandomValues |
358.60 ns | 16.16x |
localtunnels uses WebSocket + JSON. bore and frp use binary protocols. This measures per-message encode/decode cost.
| Tool | Operation | avg | vs fastest |
|---|---|---|---|
| localtunnels | JSON serialize | 123.05 ns | 1x |
| bore / frp | Binary header encode | 159.04 ns | 1.29x |
| bore / frp | Binary header decode | 176.33 ns | 1.43x |
| localtunnels | JSON parse | 434.87 ns | 3.53x |
| Tool | Strategy | avg | vs fastest |
|---|---|---|---|
| frp / bore(Go-style) | Enum-based | 2.20 ns | 1x |
| localtunnels | String-based | 2.35 ns | 1.07x |
| ngrok / Expose | Object-based | 3.54 ns | 1.61x |
| Payload | Direct | localtunnels | Overhead |
|---|---|---|---|
| 20 B | 33 µs | 102 µs | 3.1x |
| 1 KB | 30 µs | 116 µs | 3.8x |
| 64 KB | 47 µs | 350 µs | 7.5x |
| 512 KB | 144 µs | 2.08 ms | 14.4x |
| 1 MB | 234 µs | 4.16 ms | 17.8x |
| Scenario | avg |
|---|---|
| Instant response (pure overhead) | 182 µs |
| JSON API (10-item array) | 210 µs |
| With 10 ms backend | 10.44 ms (1.05x over direct) |
| With 50 ms backend | 50.51 ms (1.01x over direct) |
| Active Tunnels | Request Latency (avg) |
|---|---|
| 1 | 235 µs |
| 10 | 237 µs |
| 50 | 234 µs |
| Operation | avg |
|---|---|
| Server start + stop | 325 µs |
| Client connect + register + disconnect | 296 µs |
| 5 clients sequential | 1.61 ms |
| 5 clients concurrent | 921 µ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.
bun testPlease see our releases page for more information on what has changed recently.
Please review the Contributing Guide for details.
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the Stacks Discord Server
“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 🌎
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.
The MIT License (MIT). Please see LICENSE for more information.
Made with 💙
