How to expose localhost to the internet
Get a public HTTPS URL for your local dev server. Receive webhooks, test OAuth, demo to clients, all without deploying.
Why you need a public URL for localhost
Your dev server runs on localhost:3000. That address is a loopback -- it exists only on your machine. Nothing outside your computer can reach it. Not a webhook from Stripe. Not your phone on the same Wi-Fi. Not the client who wants to see your progress.
This comes up constantly:
Webhooks. Stripe, GitHub, Slack, Twilio -- they all deliver events by POSTing to a URL you provide. That URL must be publicly reachable. Without one, you deploy to staging every time you change a line in your webhook handler.
OAuth callbacks. Most providers reject http://localhost as a redirect URI for web apps. You need a real HTTPS URL to test the full auth flow without hacking around your config.
Live demos. A teammate, a PM, a client -- someone needs to see what you are building right now. Not after you push, wait for CI, and deploy. Right now.
Mobile testing. Your phone cannot resolve localhost on your laptop. If your mobile app talks to a local API, you need that API on a real address.
API integrations and CI. Third-party services that need to call back into your app during a build, a test suite, or an integration flow need a routable endpoint.
Service workers and secure contexts. Browsers restrict service workers, geolocation, camera access, and other APIs to secure contexts. That means HTTPS. A tunnel gives you a valid TLS cert without touching OpenSSL.
The old way: port forwarding
The traditional answer is port forwarding on your router. You open a port, point it at your machine's local IP, and hand out your public IP.
It works. It is also miserable for development:
# The port forwarding checklist nobody enjoys:
1. Log into router admin (hope you know the password)
2. Find port forwarding settings (differs per manufacturer)
3. Map external port 3000 -> 192.168.1.42:3000
4. Look up your public IP: curl ifconfig.me
5. Give out http://73.162.xx.xx:3000
6. Realize your IP changed the next morning
7. Realize there is no HTTPS
8. Realize you left port 3000 open on your firewall for two weeks
The problems stack up:
- Dynamic IPs. Residential ISPs rotate your public IP. Your URL breaks overnight.
- No HTTPS. Setting up TLS certificates for a home IP is its own project. Without it, OAuth providers reject your callback, service workers refuse to register, and browsers flag the page.
- Firewall exposure. You are punching a hole in your network. It stays open until you remember to close it.
- CGNAT. Carrier-grade NAT is increasingly common. Behind it, port forwarding simply does not work -- there is no public IP to forward to.
- Router access. On a corporate network, a coworking space, or a coffee shop, you cannot touch the router.
Port forwarding is a system administration task. You want to ship a feature.
What a tunnel actually does
A tunnel flips the model. Instead of accepting inbound connections (which requires open ports and public IPs), your machine initiates an outbound connection to a relay server. The relay server assigns a public URL and forwards traffic back through that connection.
Here is the step-by-step flow when someone hits your tunnel URL:
Internet Your Machine
------- ------------
1. Browser requests ------> https://bafoli.taupi.dev
|
2. Relay server terminates TLS, reads Host header,
finds which tunnel owns "bafoli" subdomain
|
3. Relay forwards request ------> Taupi CLI (outbound WebSocket)
|
4. Taupi CLI sends request ------> localhost:3000
|
5. Your server responds <------ Response travels back
through the same path the same WebSocket
|
Browser gets the response <------ Relay sends it to the client
The key detail: your machine never opens a port. The taupi CLI initiates the connection outward, over a standard HTTPS port that every firewall allows. This is why tunnels work behind NAT, VPNs, corporate firewalls, and CGNAT -- all the situations where port forwarding fails.
Step by step: install taupi and start a tunnel
Install
curl -fsSL https://taupi.dev/install.sh | bash
This detects your OS and architecture and drops the binary into /usr/local/bin (or ~/.local/bin if that is not writable). Confirm it is there:
taupi version
# taupi 0.x.x
Authenticate
The fastest path:
taupi login
This opens your browser for sign-up or sign-in and saves the API key automatically. If you prefer managing keys manually, create one from the dashboard and save it:
taupi auth tk_your_api_key_here
For CI and scripts, set the environment variable instead -- it takes precedence over the config file:
export TAUPI_KEY=tk_your_api_key_here
Start the tunnel
Make sure your local server is running on port 3000 (or whatever port you use), then:
taupi tunnel 3000
Output:
Taupi
Status connected
URL https://bafoli.taupi.dev
Target localhost:3000
That URL is live. If your server binds to a specific address, you can pass it explicitly:
taupi tunnel 127.0.0.1:8080
Verify it works
From another machine, your phone, or just another terminal:
curl -i https://bafoli.taupi.dev
You should see the same response your server returns on localhost:3000. If your server logs requests, you will see it appear there too.
For deeper visibility, add --inspect:
taupi tunnel --inspect 3000
This prints request and response headers and bodies directly in your terminal as traffic flows through. Invaluable when you are debugging a webhook payload and need to see exactly what arrived.
Stop
Ctrl+C. The URL stops working immediately. Nothing is left running, no ports are open, no cleanup needed. If the connection drops while running (laptop sleep, network switch), taupi reconnects automatically.
HTTPS: why it matters more than you think
Every taupi tunnel gets a valid TLS certificate. This is not a convenience feature -- it unlocks things that plain HTTP blocks:
# This works. No self-signed cert warnings, no browser complaints.
curl https://bafoli.taupi.dev
What HTTPS gives you:
- Service workers. Browsers require a secure context. Without HTTPS,
navigator.serviceWorker.register()throws. - Secure cookies. Cookies with
SecureandSameSite=None(common in auth flows) are only sent over HTTPS. - OAuth redirect URIs. Google, GitHub, and most providers require HTTPS for web app callbacks.
- No mixed content warnings. If your page loads resources from both HTTP and HTTPS origins, browsers block the HTTP ones or flag the page.
- Mobile transport security. iOS App Transport Security and Android's cleartext traffic policy block plain HTTP by default. An HTTPS tunnel means no workarounds.
TLS terminates at the relay server. Traffic from the taupi CLI to your local server is plain HTTP over loopback, which is standard for local development and does not add latency.
Custom subdomains
Free tunnels get a random subdomain that changes when the tunnel restarts. That is fine for a quick test, but painful when you have registered the URL as a webhook endpoint or shared it in a Slack thread.
On the Pro plan, register a subdomain and pin it:
taupi subdomain register myapp
taupi tunnel -s myapp 3000
Now your tunnel is always https://myapp.taupi.dev. Restart the tunnel, restart your machine, come back tomorrow -- same URL. This is critical for:
- Webhook integrations. Register the URL once in Stripe, GitHub, or Slack. It survives reconnects.
- OAuth redirect URIs. Configure it once in the provider dashboard.
- Team sharing. Drop the URL in a Jira ticket or a Slack channel. It works whenever your tunnel is up.
You get up to 20 custom subdomains and 5 simultaneous tunnels on Pro ($5/mo).
Security: what you are exposing and what you are not
When you start a tunnel, your entire local server is reachable through the tunnel URL. Every route, every endpoint, every static file. Be deliberate about what is running.
Only the port you specify. The tunnel forwards traffic to a single port. Your database on port 5432, your Redis on 6379 -- those are not exposed. Only the port you pass to taupi tunnel.
Random URLs are unguessable, not secret. A subdomain like bafoli has enough entropy that nobody will stumble on it by accident. But do not treat it as access control. If someone gets the URL (from a log, a screenshot, a Slack message in a public channel), they can reach your server.
Free tunnels expire after 5 minutes. This limits accidental exposure. The tunnel closes, the URL stops resolving, and a new random subdomain is assigned next time.
Pro tunnels persist. They stay up until you stop them. Be intentional about how long you leave a tunnel running. If you walked away from your laptop and the tunnel is still up, your dev server is still on the internet.
Add auth if you need it. For anything beyond a quick test, consider basic auth on your server:
// Express.js -- add basic auth in one line
const basicAuth = require('express-basic-auth');
app.use(basicAuth({ users: { 'dev': 's3cret' }, challenge: true }));
Never tunnel production traffic. Tunnels are for development. They are not designed for uptime, scale, or the kind of security posture production demands.
Using tunnels in scripts and CI
Tunnels work well in automated environments. Start one in the background, run your tests against the public URL, then kill it.
#!/bin/bash
# ci-webhook-test.sh
export TAUPI_KEY=tk_your_ci_api_key
# Start tunnel in background, capture the URL
taupi tunnel 3000 &
TAUPI_PID=$!
sleep 3
# Get the assigned URL (taupi writes it to stdout)
TUNNEL_URL=$(taupi status --url)
# Register the URL with your webhook provider's API
curl -X POST https://api.example.com/webhooks \
-H "Authorization: Bearer $PROVIDER_TOKEN" \
-d "{\"url\": \"${TUNNEL_URL}/webhook\"}"
# Run your test suite against the tunnel
npm test
# Clean up
kill $TAUPI_PID
In a GitHub Actions workflow:
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://taupi.dev/install.sh | bash
- run: |
taupi tunnel 3000 &
sleep 3
npm start &
sleep 3
curl --fail https://$(taupi status --url)/health
npm test
env:
TAUPI_KEY: ${{ secrets.TAUPI_KEY }}
Alternatives
Taupi is not the only tool in this space. Here is how it compares at a glance:
| taupi | ngrok | Cloudflare Tunnel | localtunnel | |
|---|---|---|---|---|
| Free tier | Yes, no signup | Yes, signup required | Yes, Cloudflare account | Yes, open source |
| HTTPS | Automatic | Automatic | Automatic | Automatic |
| Custom subdomains | Pro ($5/mo) | Paid plans ($8/mo+) | Via DNS config | Unreliable |
| TCP tunnels | No | Yes | Yes | No |
| Setup time | ~30 seconds | ~2 minutes | ~10 minutes | ~30 seconds |
For detailed breakdowns, see taupi vs ngrok, taupi vs Cloudflare Tunnel, and taupi vs localtunnel.
Troubleshooting
Tunnel connects but requests return 502. Your local server is not running on the port you specified, or it crashed after the tunnel started. Verify with:
curl -i http://localhost:3000
If that fails, the problem is your server, not the tunnel.
Tunnel URL gives ERR_CONNECTION_REFUSED. The taupi process is not running. Check that it is still alive in your terminal. If your laptop went to sleep, taupi reconnects automatically on wake -- give it a few seconds.
Webhook provider says the URL is unreachable. Some providers validate the URL when you register it. Make sure the tunnel is running before you save the webhook configuration. Also confirm your server responds with a 2xx -- some providers reject endpoints that return errors during validation.
Hot reload triggers break the tunnel. They do not. The tunnel connection is between the taupi CLI and the relay server. Your dev server restarting behind the tunnel is invisible to the tunnel itself. If you see a brief error, it is because the dev server was mid-restart when a request arrived. This resolves in a second or two.
Response is slow through the tunnel. Tunnel adds a round trip to the relay server. For most dev work this is 50-150ms. If latency is higher, check your relay region -- taupi picks the closest one automatically, but network conditions vary. For latency-sensitive testing, test locally and use the tunnel only for external access.
CORS errors in the browser.
The browser sees bafoli.taupi.dev as the origin, but your server might be sending Access-Control-Allow-Origin: localhost:3000. Update your CORS config to allow the tunnel origin, or use a wildcard in development:
// Express CORS config for development
const cors = require('cors');
app.use(cors({ origin: true }));