Back to All Cheatsheet Libraries cheatsheets

localtunnel

Public HTTPS URLs for your localhost in one command: every CLI flag, the Node API, a full safety guide for exposing local servers, and how to self-host your own relay.

A public HTTPS URL for whatever's running on your laptop

localtunnel opens an outbound connection from your machine to a relay server and hands you back a public https://something.loca.lt address that forwards every request to a local port. No router config, no port forwarding, no firewall rule — it works from behind NAT, corporate Wi-Fi, or a coffee shop. Built on Node.js, open source, and small enough to read in an afternoon.

The classic uses: showing a client a work-in-progress site without deploying it anywhere, testing a webhook from Stripe/GitHub/Twilio against code running on localhost, and pulling up your dev server on a phone to check mobile layout on the real network. It is not a substitute for real hosting — see the Safety Guide tab before you point it at anything that matters.

1

Install it

Needs Node.js. Global install gives you the lt command anywhere; no install at all works too, via npx.

npm install -g localtunnel # gives you the `lt` command # or, no install: npx localtunnel --port 8000 # or, as a project dependency: npm install localtunnel --save
2

Point it at whatever's running

lt --port 8000 # your url is: https://witty-fox-42.loca.lt

That's the entire tool. The URL is random each run unless you ask for a subdomain (next tab). Leave the terminal open — closing it, or Ctrl+C, ends the tunnel immediately.

3

The first-visit warning page

Open the URL in a browser and you'll hit a "Friendly Reminder" interstitial before your site loads — it's the public loca.lt service's own anti-abuse page, not your app, and only browsers see it (API/webhook calls skip it automatically). Click through once per browser/IP, or send the header below to skip it every time — handy for automated tests hitting the tunnel from a script.

curl -H "bypass-tunnel-reminder: true" https://witty-fox-42.loca.lt/api/health # or, for a browser fetch/axios call your own front-end makes: fetch(url, { headers: { 'bypass-tunnel-reminder': 'true' } })
4

The webhook loop: the actual reason most people install this

Stripe, GitHub, Twilio, Slack, and PayPal all need a public URL to POST events at — none of them can reach localhost:3000. Point the webhook config at your tunnel URL and every event lands on your dev machine, in your debugger, in real time.

lt --port 3000 --subdomain acme-webhooks # Stripe CLI equivalent for comparison: stripe listen --forward-to localhost:3000/webhook # Point Stripe/GitHub's webhook URL at: https://acme-webhooks.loca.lt/webhook
Your machine (localhost:PORT) ⇄ outbound connection ⇄ loca.lt relay ⇄ https://xxxxx.loca.lt ⇄ anyone with the link

Direction matters

The connection is outbound-initiated from your laptop, which is why no port forwarding or firewall rule is needed — same trick SSH reverse tunnels and ngrok use.

HTTPS is on the public side

The loca.lt URL is always HTTPS. Your local server can stay plain HTTP — the tunnel terminates TLS for you (that's also why it can't see or fix an invalid cert on your side without --allow-invalid-cert).

No account, no dashboard

Unlike ngrok, there's no login, no free-tier request cap you'll hit, and no web dashboard of past requests — also means no built-in request inspector or replay.

URLs aren't permanent

Stop lt and the URL is gone for good — someone else can be handed that exact subdomain next. Never bookmark one for later.

Every flag lt understands. Confirmed against the CLI source (bin/lt.js) — run lt --help to see the same list from your installed version.

Showing 0 flags
Flag Example What it does
-p, --portlt --port 8000The only required flag: the local port to expose. Everything else is optional.
-s, --subdomainlt --port 8000 --subdomain acme-previewRequest a specific name → acme-preview.loca.lt. First-come, first-served — someone else may already hold it, and it's never guaranteed, not even to you tomorrow.
-h, --hostlt --port 8000 --host https://tunnel.acme.devPoint at a different relay server instead of the default public https://localtunnel.me — your own self-hosted server (Self-Host tab) or a team's private one.
-l, --local-hostlt --port 8000 --local-host myapp.testForward to a different local hostname instead of localhost, and rewrite the Host header to match — needed by dev servers (Vite, Webpack Dev Server) that reject requests unless the Host header matches what they expect.
--local-httpslt --port 8443 --local-httpsYour local server is HTTPS (not HTTP) — tell the tunnel to speak TLS on the local hop too.
--local-cert--local-https --local-cert ./cert.pemPath to the local HTTPS server's certificate PEM, when it uses one the tunnel wouldn't otherwise trust.
--local-key--local-https --local-key ./key.pemMatching private key file for --local-cert.
--local-ca--local-https --local-ca ./ca.pemCertificate authority file, for a local dev cert signed by your own self-signed CA (mkcert, etc.).
--allow-invalid-certlt --port 8443 --local-https --allow-invalid-certSkip certificate validation entirely for the local hop — the quick way past a self-signed-cert error during local dev. Local dev only: it silently ignores cert/key/ca too, so don't reach for it out of laziness on anything real.
-o, --openlt --port 8000 --openOpens the tunnel URL in your default browser as soon as it's up. One less copy-paste.
--print-requestslt --port 8000 --print-requestsLogs a line per incoming request to your terminal — method + path, nothing fancier. The closest thing to ngrok's request inspector this tool has; there's no HTML/JSON dashboard.
Env var formPORT=3000 ltAny flag can also be set as an environment variable — handy in a docker-compose service or a CI job where you'd rather not hardcode the command.
--helplt --helpPrints this exact list from whatever version you actually have installed — flags do occasionally change between releases.

Open a tunnel from inside your own Node process

Everything the CLI does is one function call: localtunnel(port, options) returns a Promise for a tunnel object with a .url, a few events, and a .close(). Useful in a test harness that needs a real public URL for the duration of a test run, or a small script that starts a server and tunnels it in one step.

1

The minimal version

const localtunnel = require('localtunnel'); (async () => { const tunnel = await localtunnel({ port: 3000 }); console.log('tunnel url:', tunnel.url); // https://xxxxx.loca.lt tunnel.on('close', () => console.log('tunnel closed')); // later, or on process exit: // tunnel.close(); })();
2

With the options that matter

const tunnel = await localtunnel({ port: 3000, subdomain: 'acme-preview', // best-effort, may fall back to a random one host: 'https://localtunnel.me', // or your own self-hosted relay local_host: 'myapp.test', // for dev servers that check the Host header local_https: true, local_cert: './cert.pem', local_key: './key.pem', local_ca: './ca.pem', allow_invalid_cert: false, // leave false outside local dev });
3

Wrapped around a server you're spinning up in the same script

The pattern for an end-to-end test suite: start the app, tunnel it, run whatever needs the public URL (a webhook simulator, a cloud browser service), then tear both down.

const http = require('http'); const localtunnel = require('localtunnel'); async function withTunnel(port, fn) { const server = http.createServer(app).listen(port); const tunnel = await localtunnel({ port }); try { await fn(tunnel.url); } finally { tunnel.close(); server.close(); } } withTunnel(3000, async (url) => { console.log('run your webhook test against', url); });
4

Handle the events, or a dead tunnel fails silently

The relay connection can drop — restarts, network blips, a rate limit. Without an error listener, an unhandled error can crash the process (standard Node EventEmitter behavior); without a close handler, you won't notice a test run went dark until it times out.

tunnel.on('error', (err) => { console.error('tunnel error:', err.message); // decide: retry with a fresh localtunnel() call, or fail the run }); tunnel.on('close', () => { console.log('tunnel closed — expected on shutdown, unexpected mid-test'); }); tunnel.on('request', (info) => { console.log(info.method, info.path); // same data --print-requests logs on the CLI });
Option / member Type Notes
portnumber, requiredSame as the CLI's --port. The only required option.
subdomainstringRequested, not guaranteed — check tunnel.url after connecting to see what you actually got.
hoststring, default https://localtunnel.meThe relay/broker server. Point at your own localtunnel-server instance here.
local_hoststringForward to this hostname (and rewrite the Host header to it) instead of localhost.
local_httpsbooleanLocal server speaks HTTPS.
local_cert / local_key / local_castring (file path)Certificate material for the local HTTPS hop. Ignored entirely if allow_invalid_cert is true.
allow_invalid_certbooleanSkip local TLS validation. Local dev only — see the CLI tab's warning, it applies identically here.
tunnel.urlstring (readonly)The assigned public URL. Read it after the returned Promise resolves — it isn't known beforehand even when you requested a subdomain.
tunnel.close()methodTears the tunnel down. Call it in a finally block or a test framework's afterAll — an orphaned tunnel from a crashed test run stays open on the relay until it independently times out.
'request' eventeventFires per incoming request with basic method/path info — the programmatic version of --print-requests.
'error' eventeventAlways attach a listener. An EventEmitter's unhandled error event throws and can crash the process.
'close' eventeventFires when the tunnel ends, whether you called .close() or the relay dropped it.
The one thing to internalize
  • A tunnel URL has zero authentication by default. Anyone who has the link — because you sent it, because it leaked in a screenshot, because it got logged somewhere, or because someone guessed a short/common subdomain — can hit your local server exactly as if they were sitting at localhost. If that server has no auth of its own, neither does the tunnel.
  • It's a relay you don't control. The default https://localtunnel.me is a free, community-run, best-effort public service. Every request to your machine passes through it in plaintext-to-the-relay-then-reencrypted fashion (TLS terminates there). Don't send anything through it you wouldn't be comfortable a third-party operator technically being able to see.
  • Treat every tunnel as temporary and disposable — spin it up for the task, close it the moment you're done, never leave one running "just in case" overnight or over a weekend.
Showing 0 practices
Practice Why How
Never tunnel production or anything with real user dataThe tunnel, the relay, and the public URL are all outside your normal security boundary — no WAF, no rate limiting, no access log you control, no SLA.Tunnel a local copy or a seeded dev database only. If a client needs to see something real, deploy it properly (see the scp/rsync deploy section on the Linux cheatsheet) instead of tunneling prod.
Put a password in front of anything sensitiveThe tunnel forwards every request as-is; it adds zero auth of its own.Basic Auth (htpasswd, same pattern as the Linux cheatsheet's guide) in front of your dev server, or middleware that checks a shared secret header/query param before proxying through.
Rotate the subdomain like a secret, not a bookmarkA predictable or reused subdomain (--subdomain myapp every day) is guessable and gets bookmarked/logged by whoever you shared it with, past when you meant them to have access.Let it be random by default for anything short-lived; only fix a subdomain when you specifically need a stable link for a limited window (a demo call), and close the tunnel right after.
Never put real API keys or secrets in a tunneled request/response for a demoYou don't control the relay's logs, and screen-shares/recordings of a tunnel session capture the URL bar too.Use test-mode API keys (Stripe test keys, sandboxed OAuth apps) for anything you're about to expose publicly, even briefly.
Check --local-host/binding before you assume "local" means privateIf your dev server itself binds to 0.0.0.0 instead of 127.0.0.1, it's already reachable on your LAN — the tunnel just adds a second, public path to the same exposed server.lsof -iTCP -sTCP:LISTEN -P | grep node to see what your dev server actually bound to; prefer 127.0.0.1 unless you specifically need LAN access too.
Validate webhook signatures even over a tunnelAnyone who finds your webhook URL (guessed subdomain, leaked log line) can POST fake events at it. The tunnel doesn't verify anything about the sender.Keep your provider's signature check (Stripe's stripe-signature header, GitHub's X-Hub-Signature-256) active in dev exactly as it would be in production — don't special-case it away "because it's just local".
Close it when you're done, not "eventually"A forgotten tunnel from an old terminal tab is a live public entry point into your machine that nobody is watching.Ctrl+C when finished. In scripts/CI, always tunnel.close() in a finally. Periodically run ps aux | grep localtunnel to check nothing's lingering.
Don't tunnel a database or admin panel directlyphpMyAdmin, Adminer, a Postgres/Redis admin UI, or a raw DB port tunneled out is one of the most common real-world exposure mistakes — search "exposed" + any of those tool names for why this matters.Tunnel the application, not its infrastructure. If you truly need remote DB access, use an SSH tunnel to a server you control (see the SSH cheatsheet) with key-based auth, not a public relay.
Assume the URL will be scannedAutomated bots crawl *.loca.lt and similar tunnel-domain patterns looking for exactly the mistakes above. Being "obscure" isn't a security boundary.Treat every tunnel as internet-facing from second one, not "probably fine for a few minutes".
For anything client-facing or long-lived, self-host your own relayYou get to control logging, TLS, and who can reach the relay at all — removes the "shared public service" trust question entirely.Run localtunnel-server on your own VPS behind your own domain (see the Node API tab's host option) — a few commands, covered below.

Self-hosting your own relay (localtunnel-server)

The public loca.lt service is localtunnel-server running on infrastructure you don't control. Running your own — on a $6 VPS, the same one from the Linux cheatsheet's deploy workflows — puts every tunnel under your own domain and your own logs, and removes the biggest safety caveat above.

# on your VPS git clone https://github.com/localtunnel/server.git localtunnel-server cd localtunnel-server && npm install bin/server --port 1234 --domain tunnel.acme.dev # behind nginx (TLS + wildcard subdomain), reverse-proxy to 127.0.0.1:1234 — # needs *.tunnel.acme.dev wildcard DNS pointed at the VPS # then from any client machine: lt --port 3000 --host https://tunnel.acme.dev
# Docker, if you'd rather not manage Node on the VPS directly docker run -d --restart always --name localtunnel --net host \ defunctzombie/localtunnel-server:latest --port 3000

Needs wildcard DNS for the domain (every subdomain must resolve to the VPS) and a reverse proxy in front handling TLS — nginx with a wildcard cert (Let's Encrypt DNS-01 challenge) is the standard setup, same pattern as any other reverse-proxied Node app on that box.

Real-device testing on the actual mobile network
The one thing a tunnel does that localhost can't: put the URL on a phone over cellular data, not just the same Wi-Fi. Catches CORS/mixed-content/service-worker bugs a same-LAN test misses.
Vite / webpack-dev-server "Invalid Host header"
Most dev servers reject requests whose Host header doesn't match what they expect — the tunnel's public hostname trips this. Fix in the dev server config (Vite: server.allowedHosts; webpack-dev-server: allowedHosts), not by fighting localtunnel. Vite 5+: allowedHosts: ['.loca.lt'] allows any loca.lt subdomain.
WebSockets mostly work, but verify
HMR (Vite/webpack live reload), Socket.IO, and similar upgrade-based connections tunnel through fine most of the time, but the public relay is best-effort — a flaky connection drops the socket before it drops plain HTTP. If HMR feels laggy over a tunnel, that's the relay, not your code.
localtunnel vs ngrok vs Cloudflare Tunnel vs an SSH tunnel
localtunnel: free, no account, no request cap, minimal features. ngrok: account required, generous free tier, a real web request inspector/replay UI, paid tiers for reserved domains. Cloudflare Tunnel: free, needs a Cloudflare-managed domain, best for something semi-permanent. SSH reverse tunnel (ssh -R, see the Linux cheatsheet's scp/rsync section for the SSH setup) to a server you own: full control, no third party at all, more setup.
"connection refused" right after "your url is..."
The tunnel connected fine — your local server on that port isn't actually up yet, or it's on a different port than you typed. lsof -iTCP:8000 -sTCP:LISTEN to confirm what's really listening before blaming the tunnel.
Subdomain "already in use"
Someone else's tunnel currently holds that name on the shared public relay — subdomains aren't reserved accounts, first process to ask for a name during a session gets it. Pick a more specific/unusual name, or self-host (Safety Guide tab) if you need a name guaranteed yours.
Tunnel silently stops responding after a while
The free public relay has no uptime guarantee and periodically recycles connections. For anything that needs to stay up for hours (a long demo, an overnight webhook test), wrap it in a small retry loop via the Node API's error/close events, or self-host.
package name collision: localtunnel vs local-tunnel
Double-check npm info localtunnel before installing from a tutorial — several similarly-named packages exist on npm with different maintainers and different feature sets. The one this page documents is localtunnel (no hyphen), by the localtunnel GitHub org.