Skip to main content

Guide

untun vs localtunnel vs ngrok (2026)

Compare untun, localtunnel, and ngrok for exposing local dev servers to the internet. Cloudflare tunnels, webhook testing, HTTPS exposure, and which tunnel.

·PkgPulse Team·
0

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

Real-World Usage: Common Developer Scenarios

The right tool depends heavily on the use case. Here is a breakdown of which tools fit which scenarios:

Scenario 1: Quick webhook testing during development

You are building a Stripe integration and need to receive webhooks on localhost. You need it now, for 30 minutes, then you are done.

# Best: untun — zero signup, Cloudflare reliability
npx untun tunnel http://localhost:3000
# → https://random-words.trycloudflare.com

# Copy the URL into Stripe Dashboard → Webhooks → Add endpoint
# Done in 60 seconds, no account needed

Scenario 2: Sharing a dev preview with a client

You need to show a stakeholder a feature branch. The URL needs to be stable for 2 hours while they review it.

# Best: ngrok (free tier) — stable URL for the session
ngrok http 3000
# → https://abc123.ngrok-free.app
# URL stays the same until you stop ngrok

# Or: localtunnel with requested subdomain
lt --port 3000 --subdomain my-feature-preview
# → https://my-feature-preview.loca.lt
# Requested (not guaranteed) but usually works

Scenario 3: CI/CD integration testing — webhook receiver in GitHub Actions

You need a public URL in a GitHub Actions workflow to receive webhooks during automated tests.

// Best: untun — programmatic API, no account needed in CI
import { startTunnel } from "untun"
import { test, beforeAll, afterAll } from "vitest"

let tunnel: Awaited<ReturnType<typeof startTunnel>>

beforeAll(async () => {
  tunnel = await startTunnel({ port: 3000 })
  const url = tunnel.getURL()
  // Register webhook URL with external service
  await registerWebhook(url + "/webhooks/stripe")
})

afterAll(async () => {
  await tunnel.close()
})

test("stripe webhook handler", async () => {
  // ... test runs, webhook arrives at the tunnel URL
})

Scenario 4: Production-like environment with auth and custom domain

You need a stable https://staging.myapp.com-style URL for a staging environment accessed by the team, with basic auth to prevent public access.

# Best: ngrok (paid) — stable custom domain + basic auth
ngrok http 3000 --domain=staging.myapp.ngrok.io --basic-auth "team:secretpassword"

Community Adoption and Pricing in 2026

ToolWeekly DownloadsGitHub StarsFree TierPaid Tier
untun~200K800+Unlimited (Cloudflare Quick Tunnels)N/A
localtunnel~500K18,000+Unlimited (community server)N/A (self-host)
ngrok~300K10,000+1 agent, random URLs$8-20/mo for stable URLs

localtunnel's 18K GitHub stars versus its 500K weekly downloads show an interesting dynamic — it is the "open source alternative" that developers star to remember, but many end up using ngrok or untun for actual day-to-day work because localtunnel's shared server is less reliable than Cloudflare's infrastructure (untun) or ngrok's paid infrastructure.

The free tier story: untun is the most compelling for zero-cost usage in 2026. Cloudflare's Quick Tunnels are fast, reliable, and have no rate limits. The only limitation is that URLs change each session — there is no way to get a stable URL without a Cloudflare account and a paid Cloudflare Tunnel subscription. For development workflows where you just need a URL that lasts the session, untun is the best free option.

Migration Guide

From ngrok to untun (for zero-account tunneling):

// Before (ngrok):
import ngrok from "@ngrok/ngrok"
const listener = await ngrok.forward({
  addr: 3000,
  authtoken: process.env.NGROK_AUTHTOKEN,
})
const url = listener.url()

// After (untun):
import { startTunnel } from "untun"
const tunnel = await startTunnel({ port: 3000 })
const url = tunnel.getURL()

// Remove NGROK_AUTHTOKEN from environment — not needed

From localtunnel to untun:

// Before (localtunnel):
import localtunnel from "localtunnel"
const tunnel = await localtunnel({ port: 3000 })
const url = tunnel.url

// After (untun):
import { startTunnel } from "untun"
const tunnel = await startTunnel({ port: 3000 })
const url = tunnel.getURL()

// Note: untun auto-downloads cloudflared binary on first run
// This adds ~30 seconds on first run, then it is cached

Limitations and Known Issues

untun — cloudflared binary download

untun auto-downloads the cloudflared binary on first run. This means the first time you or your CI environment uses untun, there is a 10-30 second delay while it downloads ~50MB. Subsequent runs use the cached binary. In CI environments, you can pre-install cloudflared to avoid this:

# GitHub Actions — pre-install cloudflared:
- name: Install cloudflared
  run: |
    curl -L --output cloudflared.deb \
      https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
    sudo dpkg -i cloudflared.deb
    rm cloudflared.deb

localtunnel — shared server instability

The official localtunnel shared server (localtunnel.me) is community-run and experiences occasional outages. For production-critical workflows, either self-host the server or use ngrok/untun. Self-hosting is straightforward with the localtunnel server package:

# Self-host localtunnel on a VPS:
npm install -g localtunnel
# server runs at https://your-server.com
# Clients connect with: lt --host https://your-server.com --port 3000

ngrok — URL changes on reconnect

On the free tier, ngrok generates a new random URL each time you start it. This means webhook URLs need to be re-registered after every restart. Teams that hit this limitation frequently either upgrade to ngrok's paid tier for stable domains or switch to a self-hosted solution.

The ngrok Agent SDK (the @ngrok/ngrok npm package) mitigates reconnect issues by supporting automatic reconnection with the same configuration, but the URL still changes unless you have a paid ngrok plan with static domains.

All three — corporate firewall restrictions

Many corporate environments block outbound connections to tunneling services. If you see connection timeouts when using any of these tools inside a corporate network, the firewall may be blocking the service's egress ports. untun (Cloudflare) uses port 443, which is rarely blocked. ngrok also supports port 443. localtunnel defaults to port 80/443 for the tunnel but uses custom ports between client and server.

Integration with Development Servers and Framework Hot Reload

The friction of starting a tunnel alongside your development server depends on how well each tool integrates with your framework's CLI. Untun's integration with Nuxt and Nitro is first-class — enabling devServer.tunnel: true in your config starts the Cloudflare tunnel automatically when nuxt dev runs, outputs the public URL alongside the local URL, and tears down the tunnel when you stop the dev server. This eliminates the two-terminal workflow (one for the dev server, one for the tunnel) that every other framework-tunnel combination requires.

For frameworks without built-in tunnel support (Next.js, SvelteKit, Vite-based apps), the typical setup is a package.json script that chains the dev server with a tunnel: "dev:tunnel": "concurrently 'next dev' 'untun tunnel http://localhost:3000'". The concurrently package manages both processes and forwards signals correctly so Ctrl+C stops both. ngrok's Node.js SDK enables a more elegant integration where the tunnel is started programmatically inside your application's startup code and the URL is available as a JavaScript variable — useful for frameworks that expose a server startup hook.

Security Considerations When Using Tunnels

Exposing localhost to the public internet carries real security risks that developers sometimes underestimate during the "it's just for testing" phase. Any tunnel URL is publicly accessible by default — if the URL leaks into a screenshot, Slack message, or public commit, anyone who sees it can access your local development server, which may include authentication-bypassed admin routes, debug endpoints, or database admin interfaces running on your local machine.

Ngrok addresses this most directly with its authentication features. Basic auth (--basic-auth "user:password") adds a browser authentication challenge to every request, preventing accidental public access. Ngrok's IP restrictions on paid tiers let you allowlist specific IP ranges, making the tunnel inaccessible to anyone outside your office network or VPN. These features are absent in untun and localtunnel, which are entirely open by design. If you need a tunnel for anything beyond a short-lived webhook test with a known external service, ngrok's access controls make it meaningfully safer.

Cloudflare's Quick Tunnels (the infrastructure behind untun) have their own security model: each URL is cryptographically random and does not appear in any directory or index. The probability of a random external party guessing a URL like orange-fire-abc123.trycloudflare.com during your 30-minute test session is negligible. However, "security through obscurity" is not a substitute for authentication when the local server handles sensitive data. The practical guidance is to use untun and localtunnel for webhook receivers and API integration testing (where the only caller is a known external service you've registered the URL with), and use ngrok with basic auth for anything a human browser might navigate to.

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 →

See also: get-port vs detect-port vs portfinder and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.