← All guides

Share your local dev server with anyone

Give teammates, clients, and testers a public URL to your localhost. No deploy, no staging, no waiting.

The scenario

You are building a feature. It runs on your machine. Someone else needs to see it. Maybe a designer wants to check spacing on their phone. Maybe a client wants a progress update. Maybe QA wants to start testing before you merge.

The traditional answer is: push, wait for CI, deploy to staging. That is a 10-minute loop at best, and you will repeat it six times before everyone is happy.

The faster answer: generate a public URL that points straight at your localhost, copy it, paste it in Slack. Done in 15 seconds.

# Your app is running on port 3000
taupi 3000
  Taupi

  Status   connected
  URL      https://bafoli.taupi.dev
  Target   localhost:3000

Send https://bafoli.taupi.dev to whoever needs it. They open it in their browser and see exactly what you see on localhost:3000. Your code keeps running, hot reload keeps working, and you skip the entire deploy cycle.

Quick share vs. persistent URL

There are two modes, and which one you want depends on the situation.

Random URL (free). Every time you start a tunnel, taupi assigns a random subdomain. This is perfect for one-off shares: "hey, look at this real quick." The URL changes if you restart, and free tunnels expire after 5 minutes. Nobody will stumble on it by accident, and you do not need to clean up after yourself.

taupi 3000
# https://bafoli.taupi.dev -- works for 5 minutes

Custom subdomain (Pro). Register a subdomain once, and it is yours. The URL survives tunnel restarts, laptop reboots, network changes. Drop it in a Jira ticket, a Slack channel, a project doc -- it works whenever your tunnel is up.

taupi 3000 --subdomain checkout-feature
# https://checkout-feature.taupi.dev -- same URL every time

For anything that lasts longer than a single conversation ("test this over the next few days", "use this for the sprint"), use a custom subdomain. You get up to 20 on the Pro plan ($5/mo).

Design review workflow

The designer needs to see your work on their own device. They want to test responsive behavior, check touch interactions, verify animations on a real phone. Deploying to staging every time you adjust padding is not a workflow -- it is a punishment.

A better loop:

# Terminal 1: dev server with hot reload
npm run dev
# http://localhost:3000

# Terminal 2: tunnel
taupi 3000 --subdomain design-review
# https://design-review.taupi.dev

Send the URL. The designer opens it on their laptop, their phone, their tablet. You change CSS, save the file, and hot reload pushes the update through the tunnel. They refresh (or it auto-refreshes, depending on your setup) and see the change immediately.

This works because the tunnel is transparent. It forwards HTTP traffic byte-for-byte, including WebSocket connections that hot reload uses. The designer's browser connects to design-review.taupi.dev, the tunnel forwards the WebSocket upgrade to your dev server, and hot module replacement works exactly like it does on localhost.

Designer's browser
    |
    |  HTTPS + WebSocket
    v
design-review.taupi.dev (relay)
    |
    |  tunnel connection
    v
Your machine -> localhost:3000 (Vite/webpack HMR)

No special configuration required. If hot reload works on localhost, it works through the tunnel.

Client demos

A non-technical client wants to see progress. They are not going to clone your repo. They are not going to install anything. They need a link they can click.

taupi 3000 --subdomain acme-demo

Send them https://acme-demo.taupi.dev. It is a clean URL -- no port numbers, no random strings, no HTTP warnings. It looks like a real website. They click it, they see your app, they give feedback while you are on the call together.

If they spot something ("can you make that button bigger?"), you change the code, save, and tell them to refresh. They see the update in seconds. This is dramatically more effective than sharing screenshots or recording videos.

When the demo is over, Ctrl+C. The URL stops working. Nothing to tear down, no staging environment to remember to shut off.

QA testing on a feature branch

Your QA engineer wants to test a feature before it merges. Instead of waiting for a preview deploy:

git checkout feature/new-checkout
npm run dev

# In another terminal
taupi 3000 --subdomain qa-checkout

QA opens https://qa-checkout.taupi.dev and starts testing. You watch requests come in with --inspect:

taupi 3000 --subdomain qa-checkout --inspect
  Taupi

  Status   connected
  URL      https://qa-checkout.taupi.dev
  Target   localhost:3000
  Inspect  enabled

  POST /api/checkout 200 142ms
  GET  /api/products 200 89ms
  POST /api/checkout 422 12ms  <-- they hit a validation error

QA reports a bug. You see the exact request that triggered it. You fix the code, save, and QA refreshes. The feedback loop drops from hours (push, deploy, test, report, fix, repeat) to minutes.

Protecting shared tunnels

A taupi URL is public. Anyone with the link can reach your server. For most dev sharing -- you send the URL to one person in a DM -- this is fine. The random subdomain has enough entropy that nobody will guess it.

When you need more control, add authentication at the application level.

Express.js:

const basicAuth = require('express-basic-auth');

// Only enable auth when sharing via tunnel
if (process.env.TUNNEL_AUTH) {
  app.use(basicAuth({
    users: { 'reviewer': process.env.TUNNEL_PASSWORD || 'changeme' },
    challenge: true, // browser shows a login prompt
  }));
}
TUNNEL_AUTH=1 TUNNEL_PASSWORD=s3cret npm run dev

Flask:

from flask_httpauth import HTTPBasicAuth
import os

auth = HTTPBasicAuth()

@auth.verify_password
def verify(username, password):
    if not os.environ.get('TUNNEL_AUTH'):
        return True  # no auth when running locally without tunnel
    return username == 'reviewer' and password == os.environ.get('TUNNEL_PASSWORD', 'changeme')

@app.before_request
@auth.login_required
def require_auth():
    pass
TUNNEL_AUTH=1 TUNNEL_PASSWORD=s3cret flask run

Share the credentials alongside the URL: "here is the link, password is s3cret." This prevents drive-by access if the link ends up somewhere unintended.

For anything beyond dev sharing (SSO, team permissions, audit logs), use a proper staging environment with your identity provider. Tunnels are not access control infrastructure.

Running multiple tunnels

On the Pro plan you get 5 simultaneous tunnels. This matters when your stack has separately-running services.

# Terminal 1: React frontend
cd frontend && npm run dev  # port 3000
taupi 3000 --subdomain myapp

# Terminal 2: API server
cd api && npm run dev  # port 8080
taupi 8080 --subdomain myapp-api

# Terminal 3: docs site
cd docs && npm run dev  # port 4000
taupi 4000 --subdomain myapp-docs

Your reviewer gets three URLs:

  • https://myapp.taupi.dev -- the frontend
  • https://myapp-api.taupi.dev -- the API (useful for testing with curl or Postman)
  • https://myapp-docs.taupi.dev -- the docs

If the frontend needs to talk to the API, update the API base URL in your frontend config to point at the API tunnel:

// .env.development or config.js
NEXT_PUBLIC_API_URL=https://myapp-api.taupi.dev

Now the reviewer's browser loads the frontend from one tunnel and makes API calls to the other. Both resolve to your machine.

Framework-specific configuration

Most dev servers bind to localhost or 127.0.0.1 by default, and taupi connects to localhost, so things usually work out of the box. But some frameworks need a nudge.

Next.js

Next.js works with tunnels without changes. One thing to watch: if you are using NEXTAUTH_URL or similar environment variables for auth callbacks, set them to the tunnel URL:

# .env.local
NEXTAUTH_URL=https://myapp.taupi.dev

If you use server actions or API routes that check the Host header, they will see myapp.taupi.dev instead of localhost:3000. This is usually fine, but if you have host validation middleware, add the tunnel domain:

// next.config.js
module.exports = {
  allowedDevHosts: ['myapp.taupi.dev'],
};

Vite

Vite's dev server binds to localhost by default. This works with taupi. However, Vite's HMR WebSocket connection needs to know the correct host. If hot reload breaks through the tunnel, configure the HMR client:

// vite.config.js
export default {
  server: {
    hmr: {
      // Tell the HMR client to connect via the tunnel
      host: 'myapp.taupi.dev',
      protocol: 'wss', // tunnel is HTTPS, so WebSocket must be WSS
    },
  },
};

In most cases Vite detects this automatically from the page URL, but if HMR falls back to polling or stops updating, this config fixes it.

Rails

Rails 7+ blocks requests from unknown hosts by default in development. If you see a Blocked host error through the tunnel:

# config/environments/development.rb
Rails.application.configure do
  # Allow requests through the tunnel
  config.hosts << "myapp.taupi.dev"

  # Or, less restrictive (fine for dev):
  # config.hosts.clear
end

Rails binds to localhost by default, which works. If you want to also access the server from other devices on your LAN simultaneously:

bin/rails server -b 0.0.0.0

Django

Django has ALLOWED_HOSTS which rejects requests with unrecognized Host headers:

# settings.py (development only)
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'myapp.taupi.dev']

If you use a custom subdomain that changes per feature branch, use a pattern:

ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.taupi.dev']

The leading dot allows any subdomain under taupi.dev.

Django binds to 127.0.0.1:8000 by default. taupi connects to localhost which resolves to 127.0.0.1, so this works without changes:

python manage.py runserver 8000
taupi 8000 --subdomain myapp

Tips from actual use

Name your subdomains after the work, not the person. checkout-redesign.taupi.dev tells your teammate what they are looking at before they click. johns-tunnel.taupi.dev does not.

Tell people when you shut down. The URL stops working the moment you close the tunnel. A quick "tunnel is down" message in Slack prevents confused bug reports.

Use tmux or screen for long sessions. If you are sharing a URL for the afternoon, you do not want an accidental terminal close to kill the tunnel:

tmux new-session -d -s tunnel 'taupi 3000 --subdomain checkout-redesign'

Detach from tmux, close the terminal, the tunnel keeps running.

Laptop sleep kills everything. The tunnel reconnects automatically when you wake up, but there is a gap. For multi-day sharing, run on a machine that stays awake, or accept the occasional "is the link down?" message.

Check your dev server's console output. When someone reports "the page is blank" or "I get an error," your dev server's terminal usually has the stack trace. The tunnel forwards the request faithfully -- if something is broken, the issue is in your code, not the tunnel.

Keep .env files separate. If you add tunnel-specific config (like NEXTAUTH_URL), use .env.local or .env.development so it does not bleed into production. Better yet, gate it on an environment variable:

const BASE_URL = process.env.TUNNEL_URL || `http://localhost:${PORT}`;