Skip to main content

untun vs localtunnel vs ngrok: Local Tunnel and Dev Exposure Tools (2026)

·PkgPulse Team

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

Featureuntunlocaltunnelngrok
BackendCloudflareCustom serverngrok cloud
PricingFreeFreeFreemium
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 downloadN/A❌ (manual install)
Framework integrationNuxt/NitroManualManual
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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.