Share Your Local Dev Server in 30 Seconds

Stop emailing screenshots. Give your client a live URL to the thing running on your laptop -- right now, tonight, no deploy required.

12 min read Tutorial 3 of 5 Intermediate

You've claimed a stable port. Your dev server is running. Now you need someone else to see it -- a client across town, a webhook from Stripe, your phone on the couch. Port Daddy's tunnel integration makes that a single command.

Why You Need Tunnels

Every developer hits these moments:

Without Port Daddy, you'd juggle separate tunnel CLIs, remember which port maps to which tunnel, and manually restart tunnels when your dev server bounces. Port Daddy unifies all of it.

How It Works

Port Daddy doesn't reinvent tunneling. It wraps the three most popular providers and manages the lifecycle for you:

Provider Best For Auth Required
ngrok Production-quality tunnels, custom domains, dashboard Free account (optional for basic use)
cloudflared Zero-config, no signup, fast Cloudflare network None
localtunnel Quick and dirty, npm-native, no binary install None

When you run pd tunnel start, Port Daddy:

  1. Looks up the service's port from your claim
  2. Auto-detects which providers are installed on your machine
  3. Starts the tunnel process and captures the public URL
  4. Stores the URL alongside the service in the registry
  5. Monitors the tunnel process and logs activity

Installing a Tunnel Provider

First, check what you already have:

$ pd tunnel providers
ngrok        installed    v3.6.0
cloudflared  installed    2024.2.1
localtunnel  not found

If nothing is installed, pick one:

ngrok (Recommended)

The gold standard. Stable URLs, a web dashboard at localhost:4040, request inspection, and replay.

# macOS
$ brew install ngrok

# npm (cross-platform)
$ npm install -g ngrok

# Optional: authenticate for stable URLs
$ ngrok config add-authtoken YOUR_TOKEN

cloudflared

Zero signup, zero config. Cloudflare's network is fast, and the URLs are temporary by default.

# macOS
$ brew install cloudflare/cloudflare/cloudflared

# Linux
$ curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
  -o /usr/local/bin/cloudflared
$ chmod +x /usr/local/bin/cloudflared

localtunnel

The npm-native option. No binary to install, just a package.

$ npm install -g localtunnel

Localtunnel is the simplest to set up, but URLs change on every restart and it can be less reliable under load.

Starting a Tunnel

You need a claimed port first. Tunnels attach to existing services:

# Step 1: Claim a port and start your dev server
$ pd claim myapp:frontend
Port 3100 assigned to myapp:frontend

$ PORT=$(pd claim myapp:frontend -q) npm run dev

# Step 2: Open a tunnel (in another terminal)
$ pd tunnel start myapp:frontend
Tunnel started for myapp:frontend
Provider:  ngrok
Public URL: https://abc123.ngrok-free.app
Local port: 3100

That's it. The URL is live. Send it to your client, paste it into a Stripe webhook config, or open it on your phone.

Want to use a specific provider?

# Force cloudflared instead of the default
$ pd tunnel start myapp:frontend --provider cloudflared
Tunnel started for myapp:frontend
Provider:  cloudflared
Public URL: https://random-words-here.trycloudflare.com
Local port: 3100

Managing Active Tunnels

See all active tunnels at a glance:

$ pd tunnel list
SERVICE             PROVIDER     PORT   URL
myapp:frontend      ngrok        3100   https://abc123.ngrok-free.app
myapp:api           cloudflared  3101   https://random-words.trycloudflare.com

Check a specific tunnel's status:

$ pd tunnel status myapp:frontend
Service:   myapp:frontend
Provider:  ngrok
Port:      3100
URL:       https://abc123.ngrok-free.app
Status:    active
Started:   2 minutes ago

Stop a tunnel when you're done sharing:

$ pd tunnel stop myapp:frontend
Tunnel stopped for myapp:frontend

Real Scenarios

Scenario 1: Show the Client at 11pm

Your client texts: "Can I see the new checkout flow?" You're in pajamas. No problem.

# Your dev server is already running on its stable port
$ pd tunnel start myapp:frontend
Public URL: https://abc123.ngrok-free.app

# Text the URL to your client. Done.
# They see your live local dev server over HTTPS.

No deploy. No staging environment. No waiting for CI. Your laptop becomes a server for exactly as long as you need it.

Scenario 2: Webhook Debugging with Stripe

Stripe needs a public URL for webhook events. Port Daddy makes this seamless:

# Start your API and expose it
$ pd claim myapp:api
Port 3101 assigned to myapp:api

$ pd tunnel start myapp:api
Public URL: https://def456.ngrok-free.app

# Now configure Stripe's webhook endpoint:
# https://def456.ngrok-free.app/webhooks/stripe

# Every webhook POST from Stripe hits your local machine.
# Set breakpoints, inspect payloads, debug in real time.

If you're using ngrok, open localhost:4040 to inspect every request and response in detail. Replay failed webhooks with a single click.

Scenario 3: Mobile Testing

Your responsive layout looks perfect in Chrome DevTools. Does it actually work on a real phone?

$ pd tunnel start myapp:frontend
Public URL: https://abc123.ngrok-free.app

# Open on your phone's browser. Test touch events,
# viewport quirks, and actual mobile performance.
# Changes in your editor appear live on the phone.

Hot reload works through the tunnel. Edit a component, save, and watch it update on your phone in real time.

Scenario 4: Cross-Team Integration

The backend team needs to test against your frontend, but they're in a different timezone:

# Expose both your frontend and API
$ pd tunnel start myapp:frontend
Public URL: https://abc123.ngrok-free.app

$ pd tunnel start myapp:api
Public URL: https://def456.ngrok-free.app

# Share both URLs in Slack.
# They hit your local services like they're deployed APIs.

JavaScript SDK Integration

Manage tunnels programmatically from your Node.js scripts, test harnesses, or agent workflows:

import { PortDaddy } from 'port-daddy/client';

const pd = new PortDaddy();

// Check which providers are available
const providers = await pd.tunnelProviders();
console.log(providers);
// { ngrok: true, cloudflared: true, localtunnel: false }

// Start a tunnel for a claimed service
const tunnel = await pd.tunnelStart('myapp:frontend', 'ngrok');
console.log(tunnel.url);
// https://abc123.ngrok-free.app

// Check tunnel status
const status = await pd.tunnelStatus('myapp:frontend');
console.log(status.status);
// "active"

// List all active tunnels
const list = await pd.tunnelList();
for (const t of list.tunnels) {
  console.log(`${t.serviceId} -> ${t.url}`);
}

// Stop a tunnel
await pd.tunnelStop('myapp:frontend');

Automated Test Setup

Use tunnels in your E2E test harness to test against real webhook providers:

import { PortDaddy } from 'port-daddy/client';
import { test, beforeAll, afterAll } from 'vitest';

let pd;
let tunnelUrl;

beforeAll(async () => {
  pd = new PortDaddy();

  // Claim port and start tunnel
  const claim = await pd.claim('test:webhook-server');
  const tunnel = await pd.tunnelStart('test:webhook-server');
  tunnelUrl = tunnel.url;

  // Register the tunnel URL with Stripe for testing
  await stripe.webhookEndpoints.create({
    url: `${tunnelUrl}/webhooks/stripe`,
    enabled_events: ['payment_intent.succeeded'],
  });
});

afterAll(async () => {
  await pd.tunnelStop('test:webhook-server');
  await pd.release('test:webhook-server');
});

test('receives Stripe webhook', async () => {
  // Trigger a real Stripe test payment
  // Your local server receives the webhook through the tunnel
});

Security Considerations

Tunnels make your local machine reachable from the public internet. That's powerful and dangerous in equal measure. Keep these rules in mind:

1. Public URLs Are Public

Anyone with the URL can reach your dev server. Don't leave tunnels running overnight unless you intend to. Stop them when you're done:

$ pd tunnel stop myapp:frontend

2. Never Expose Credentials

If your dev server has an admin panel without auth, a debug endpoint that dumps env vars, or a database UI -- those are all reachable through the tunnel. Review what your dev server exposes before opening it up.

3. Audit Tunnel Activity

Port Daddy logs all tunnel start/stop events in the activity log:

$ pd log --type tunnel
2026-03-01T23:14:00Z  tunnel.start  myapp:frontend  ngrok  https://abc123.ngrok-free.app
2026-03-01T23:47:00Z  tunnel.stop   myapp:frontend

4. Use ngrok's Built-In Protection

ngrok supports basic auth, IP allowlists, and OAuth on its paid tiers. If you're exposing anything sensitive, consider adding a layer:

# ngrok's native auth (outside Port Daddy)
$ ngrok http 3100 --basic-auth "user:password"

5. Rate Limiting

Port Daddy's built-in rate limiting (100 requests/minute per IP) applies to the daemon API, not to your tunneled service. Your dev server is responsible for its own rate limiting when exposed publicly.

Troubleshooting

"Tunnel won't start"

Most common cause: the service isn't claimed yet. Tunnels attach to existing services.

# Check if the service exists
$ pd find myapp:frontend
myapp:frontend  port=3100  claimed

# If not found, claim it first
$ pd claim myapp:frontend

# Check if any provider is installed
$ pd tunnel providers
ngrok        not found
cloudflared  not found
localtunnel  not found

# Install one (ngrok recommended)
$ brew install ngrok

"502 Bad Gateway"

The tunnel is running, but your dev server isn't. The tunnel forwards to the port, but nothing is listening there.

# Verify your dev server is actually running
$ curl http://localhost:3100
curl: (7) Failed to connect to localhost port 3100: Connection refused

# Start your dev server first, then try the tunnel URL again
$ PORT=$(pd claim myapp:frontend -q) npm run dev

"Webhooks don't arrive"

Three things to check:

  1. URL is correct -- Copy the full tunnel URL including the path (https://abc123.ngrok-free.app/webhooks/stripe, not just the domain).
  2. Tunnel is still active -- Run pd tunnel status myapp:api to confirm it hasn't died.
  3. Firewall rules -- Some corporate networks block tunnel providers. Try switching providers:
    $ pd tunnel stop myapp:api
    $ pd tunnel start myapp:api --provider cloudflared

"URL changed after restart"

Free-tier ngrok and localtunnel generate random URLs on each start. This is expected. If you need stable URLs:

What's Next

You can now expose any local service to the world in one command. Here's where to go from here:

  1. Monorepo Mastery -- Manage 50 services with pd scan and pd up
  2. Debugging -- Diagnose phantom port conflicts and zombie processes
  3. Full API Reference -- Every endpoint, flag, and SDK method

The tunnel is the bridge between "it works on my machine" and "here, see for yourself." Use it generously.