untun vs localtunnel vs ngrok: Local Tunnel and Dev Exposure Tools (2026)
TL;DR
untun is the UnJS tunnel utility — wraps Cloudflare's cloudflared to expose local servers with zero config, free, no account needed. localtunnel is the open-source tunnel — generates random subdomains on loca.lt, free, npm package + CLI, self-hostable. ngrok is the industry-standard tunnel — stable URLs, auth, request inspection dashboard, TLS, TCP tunnels, freemium with paid tiers. In 2026: untun for quick Cloudflare-backed tunnels, localtunnel for free open-source tunnels, ngrok for production-grade tunneling with inspection.
Key Takeaways
- untun: ~200K weekly downloads — UnJS, Cloudflare tunnel, zero config, free
- localtunnel: ~500K weekly downloads — open source, random subdomains, self-hostable
- ngrok: ~300K weekly downloads — stable URLs, dashboard, auth, TCP/TLS tunnels
- All expose localhost to the public internet via a tunnel
- untun uses Cloudflare's infrastructure (fast, reliable, free)
- ngrok has the best developer experience and inspection tools
untun
untun — Cloudflare tunnel wrapper:
CLI usage
# Expose port 3000:
npx untun tunnel http://localhost:3000
# Output:
# ◐ Starting cloudflared tunnel to http://localhost:3000
# ✔ Tunnel ready at https://random-words.trycloudflare.com
# Custom port:
npx untun tunnel http://localhost:8080
# With verbose output:
npx untun tunnel --verbose http://localhost:3000
Programmatic API
import { startTunnel } from "untun"
const tunnel = await startTunnel({
port: 3000,
})
console.log(`Tunnel URL: ${tunnel.getURL()}`)
// → https://random-words.trycloudflare.com
// Close tunnel:
await tunnel.close()
With Nuxt / Nitro
// nuxt.config.ts — built-in tunnel support:
export default defineNuxtConfig({
devServer: {
tunnel: true, // Uses untun automatically
},
})
// Or in nitro.config.ts:
export default defineNitroConfig({
devServer: {
tunnel: true,
},
})
// Dev server starts with:
// Local: http://localhost:3000
// Tunnel: https://random-words.trycloudflare.com
How it works
untun uses Cloudflare's free Quick Tunnels (trycloudflare.com):
1. Downloads cloudflared binary automatically
2. Starts cloudflared with --url flag
3. Cloudflare assigns a random subdomain
4. Traffic: internet → Cloudflare edge → cloudflared → localhost
Benefits:
✅ Free, no account needed
✅ Cloudflare's global network (fast)
✅ HTTPS by default
✅ Auto-downloads cloudflared
❌ Random URL changes each time
❌ No custom domains on free tier
localtunnel
localtunnel — open-source tunnel:
CLI usage
# Install globally:
npm install -g localtunnel
# Expose port 3000:
lt --port 3000
# → your url is: https://random-slug.loca.lt
# Request a specific subdomain:
lt --port 3000 --subdomain pkgpulse
# → your url is: https://pkgpulse.loca.lt
# Custom server:
lt --port 3000 --host https://my-tunnel-server.com
# With local HTTPS:
lt --port 3000 --local-https --local-cert ./cert.pem --local-key ./key.pem
Programmatic API
import localtunnel from "localtunnel"
const tunnel = await localtunnel({
port: 3000,
subdomain: "pkgpulse", // Optional — request specific subdomain
})
console.log(`Tunnel URL: ${tunnel.url}`)
// → https://pkgpulse.loca.lt
tunnel.on("close", () => {
console.log("Tunnel closed")
})
tunnel.on("error", (err) => {
console.error("Tunnel error:", err)
})
// Close tunnel:
tunnel.close()
Self-hosted server
# Run your own localtunnel server:
npm install -g localtunnel-server
lt-server --port 1234 --domain my-tunnels.example.com
# Clients connect to your server:
lt --port 3000 --host https://my-tunnels.example.com
# Benefits of self-hosting:
# - Custom domain
# - No rate limits
# - Full control over infrastructure
# - No dependency on loca.lt service
Webhook testing
import localtunnel from "localtunnel"
import express from "express"
const app = express()
app.use(express.json())
app.post("/webhooks/stripe", (req, res) => {
console.log("Stripe webhook:", req.body.type)
res.json({ received: true })
})
app.listen(3000, async () => {
const tunnel = await localtunnel({ port: 3000 })
console.log(`Webhook URL: ${tunnel.url}/webhooks/stripe`)
// Register this URL with Stripe:
// stripe listen --forward-to ${tunnel.url}/webhooks/stripe
})
ngrok
ngrok — industry-standard tunnel:
CLI usage
# Install (macOS):
brew install ngrok
# Authenticate (one-time):
ngrok config add-authtoken YOUR_AUTH_TOKEN
# Expose port 3000:
ngrok http 3000
# → Forwarding: https://abc123.ngrok-free.app → http://localhost:3000
# Custom subdomain (paid):
ngrok http --domain=pkgpulse.ngrok.io 3000
# With basic auth:
ngrok http 3000 --basic-auth "user:password"
# TCP tunnel (for databases, SSH, etc.):
ngrok tcp 5432
# → tcp://0.tcp.ngrok.io:12345 → localhost:5432
# TLS tunnel:
ngrok tls 443
Node.js SDK
import ngrok from "@ngrok/ngrok"
// Start a tunnel:
const listener = await ngrok.forward({
addr: 3000,
authtoken: process.env.NGROK_AUTHTOKEN,
})
console.log(`Tunnel URL: ${listener.url()}`)
// → https://abc123.ngrok-free.app
// With options:
const listener2 = await ngrok.forward({
addr: 3000,
authtoken: process.env.NGROK_AUTHTOKEN,
domain: "pkgpulse.ngrok.io", // Paid feature
basic_auth: ["user:password"],
})
// Close:
await ngrok.disconnect()
Request inspection
ngrok provides a web inspection dashboard at http://localhost:4040:
Features:
✅ See all requests/responses in real-time
✅ Replay requests (retry webhooks)
✅ Inspect headers, body, timing
✅ Filter and search requests
✅ Request/response modification (paid)
http://localhost:4040/inspect/http
Request #1: POST /webhooks/stripe 200 OK 42ms
Request #2: GET /api/packages 200 OK 15ms
Request #3: POST /webhooks/github 500 ERR 120ms ← Click to inspect
Express integration
import express from "express"
import ngrok from "@ngrok/ngrok"
const app = express()
app.use(express.json())
app.get("/", (req, res) => {
res.json({ message: "Hello from PkgPulse!" })
})
app.post("/webhooks/:provider", (req, res) => {
console.log(`Webhook from ${req.params.provider}:`, req.body)
res.json({ ok: true })
})
app.listen(3000, async () => {
const listener = await ngrok.forward({ addr: 3000 })
console.log(`Public URL: ${listener.url()}`)
console.log(`Inspect: http://localhost:4040`)
})
Feature Comparison
| Feature | untun | localtunnel | ngrok |
|---|---|---|---|
| Backend | Cloudflare | Custom server | ngrok cloud |
| Pricing | Free | Free | Freemium |
| Account required | ❌ | ❌ | ✅ (free) |
| Custom subdomains | ❌ | ✅ (requested) | ✅ (paid) |
| Stable URLs | ❌ | ⚠️ (requested) | ✅ (paid) |
| HTTPS | ✅ | ✅ | ✅ |
| TCP tunnels | ❌ | ❌ | ✅ |
| Request inspection | ❌ | ❌ | ✅ |
| Self-hostable | ❌ | ✅ | ❌ |
| Basic auth | ❌ | ❌ | ✅ |
| Node.js API | ✅ | ✅ | ✅ |
| Auto-binary download | ✅ | N/A | ❌ (manual install) |
| Framework integration | Nuxt/Nitro | Manual | Manual |
| Weekly downloads | ~200K | ~500K | ~300K |
When to Use Each
Use untun if:
- Want zero-config tunneling (no signup, no install)
- Using Nuxt or Nitro (built-in support)
- Need reliable Cloudflare-backed infrastructure
- Quick one-off tunnel for testing
Use localtunnel if:
- Want free, open-source tunneling
- Need to self-host the tunnel server
- Want a simple npm package with programmatic API
- Need requested (not guaranteed) subdomains
Use ngrok if:
- Need stable, persistent tunnel URLs
- Want request inspection and replay (webhook debugging)
- Need TCP tunnels (databases, SSH)
- Building production-grade integrations
- Need auth, IP restrictions, or custom domains
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on untun v0.1.x, localtunnel v2.x, and @ngrok/ngrok v1.x.
Compare tunneling tools and developer utilities on PkgPulse →