HTTP tunnel via Cloudflare Durable Objects and WebSockets. Expose local servers to the internet with a simple CLI. Infinitely scalable with support for Cloudflare CDN caching and password protection.
npm install -g traforoExpose a local server by pointing traforo at a port:
traforo -p 3000Or let traforo auto-detect the port from a dev server command:
traforo -- pnpm dev
traforo -- next startWith a custom tunnel ID (only for services safe to expose publicly):
traforo -p 3000 -t my-appRun a command and tunnel it:
traforo -- next start
traforo -- pnpm dev
traforo -p 5173 -- vite
traforo -p 3000 -- next start # explicit port overrides auto-detectionThe tunnel URL will be:
https://{tunnel-id}-tunnel.traforo.dev
-p, --port <port> Local port to expose (optional with -- command)
-t, --tunnel-id [id] Custom tunnel ID (prefer random default)
-c, --cache [key] Enable edge caching (optional partition key)
--password <password> Protect the tunnel with a password
-h, --host [host] Local host (default: localhost)
-s, --server [url] Custom tunnel server URL
--help Show help
--version Show version
When you pass a command after --, traforo can detect the local port from the
process output. It watches stdout and stderr for addresses like these:
http://localhost:3000
localhost:5173
127.0.0.1:8080
0.0.0.0:4321
This works well with common dev servers that print their local URL when they start.
Cache responses at Cloudflare's edge so repeat requests never hit your local machine:
traforo -p 3000 --cacheWhat gets cached:
- GET requests where the origin sends cacheable
Cache-Controlheaders (public,max-age,s-maxage) - Static asset extensions use Cloudflare-like default fallback TTLs when
cache headers are missing:
200/301=120m,302/303=20m,404/410=3m
What never gets cached:
- Non-GET requests
206 Partial Contentresponses (Cache APIput()limitation)- Responses with
Set-Cookie,Cache-Control: no-store/no-cache/private - Streaming responses (SSE, ndjson)
- WebSocket connections
Requests with Authorization, Cache-Control: no-cache/no-store/max-age=0,
or Pragma: no-cache bypass edge cache lookup.
Cache partitioning lets you bust all cached content by changing the key:
traforo -p 3000 --cache v1 # first deployment
traforo -p 3000 --cache v2 # new deploy, fresh cacheEach key creates a separate cache namespace. Old entries expire via TTL.
Restrict tunnel access with a password:
traforo -p 3000 --password mysecretVisitors in a browser see a login page. After entering the correct password
a traforo-password cookie is set and they can browse normally.
Non-browser clients (curl, APIs) get a 401 Unauthorized response with
instructions to pass the password as a cookie:
curl -b 'traforo-password=mysecret' https://{tunnel-id}-tunnel.traforo.devWhen you run a command after --, traforo injects TRAFORO_URL into the
child process environment with the full public tunnel URL:
TRAFORO_URL=https://{tunnel-id}-tunnel.traforo.dev
Your app can read it directly:
const baseUrl = process.env.TRAFORO_URLTo remap it to a custom env var your app already uses, prefix the command:
traforo -p 3000 -- sh -c 'APP_URL=$TRAFORO_URL exec node server.js'
traforo -p 3000 -- sh -c 'NEXT_PUBLIC_URL=$TRAFORO_URL exec next dev'
traforo -p 3000 -- sh -c 'VITE_BASE_URL=$TRAFORO_URL exec vite'Or set it in your .env / startup script and let traforo override only
TRAFORO_URL, reading it where needed:
// next.config.js
const baseUrl = process.env.APP_URL || process.env.TRAFORO_URL || 'http://localhost:3000'Package managers like pnpm and bun prepend node_modules/.bin to PATH.
Traforo passes the full parent environment to child commands, so
project-local binaries work without pnpm exec or npx:
pnpm traforo -- vite dev
pnpm traforo -- next start
bun traforo -- wrangler devTraforo injects standard reverse-proxy headers when forwarding requests to your local server:
X-Forwarded-Host: {tunnel-id}-tunnel.traforo.dev
X-Forwarded-Proto: https
Frameworks like BetterAuth, Next.js, Express (with trust-proxy),
and Hono use these to construct correct redirect URLs and absolute links
instead of pointing back to localhost.
If your framework reads X-Forwarded-Host or X-Forwarded-Proto, redirects
and OAuth callbacks will use the public tunnel URL automatically.
When running a Cloudflare Workers project via traforo -- wrangler dev,
traforo sets CLOUDFLARE_INCLUDE_PROCESS_ENV=true in the child process
environment. This tells wrangler to pass parent env vars (including
TRAFORO_URL) as local development bindings, so process.env.TRAFORO_URL
works inside workerd.
traforo -- wrangler dev// Inside your worker:
const baseUrl = process.env.TRAFORO_URL CLI Client Cloudflare Edge Local Server
│ │ │
│ WebSocket connect │ │
│ ────────────────────► │ │
│ │ │
│ ┌─────┴─────┐ │
│ │ Durable │ HTTP request │
│ │ Object │ ◄─── browser/curl │
│ └─────┬─────┘ │
│ │ │
│ forward request via WS │ │
│ ◄──────────────────── │ │
│ │ │
│ http://localhost:PORT │
│ ──────────────────────────────────────────────────► │
│ │ │
│ ◄────────────────────────────────────────────────── │
│ response │ │
│ ────────────────────► │ │
│ ┌─────┴─────┐ │
│ │ respond │ ───► browser/curl │
│ └───────────┘ │
- Local client connects to Cloudflare Durable Object via WebSocket
- HTTP requests to tunnel URL are forwarded to the DO
- DO sends requests over WebSocket to local client
- Local client makes request to localhost and returns response
- WebSocket connections from users are also proxied through
/traforo-status Check if tunnel is online
/traforo-upstream WebSocket endpoint for local client
/traforo-login POST endpoint for password login
/* All other paths proxied to local server
import { TunnelClient } from 'traforo/client'
import { runTunnel } from 'traforo/run-tunnel'
const client = new TunnelClient({
localPort: 3000,
tunnelId: 'my-app',
cacheKey: 'v1', // optional: enable edge caching
password: 'mysecret', // optional: password protection
})
await client.connect()You can deploy your own traforo instance on Cloudflare. The worker uses Durable Objects for WebSocket coordination, so you need a Cloudflare account with the Workers Paid plan.
- A Cloudflare account with Workers Paid plan (Durable Objects require it)
- A domain added to Cloudflare DNS (e.g.
example.com) - Node.js 18+ and pnpm installed
- wrangler CLI:
npm install -g wrangler
git clone https://github.com/remorses/traforo.git
cd traforo
pnpm installEdit worker/wrangler.json and replace the routes with your domain:
{
"routes": [
{
"pattern": "*-tunnel.example.com/*",
"zone_name": "example.com"
}
]
}In the Cloudflare dashboard for your domain, add a wildcard CNAME record:
Type: CNAME
Name: *-tunnel
Target: example.com
Proxy: Proxied (orange cloud)
This routes all {id}-tunnel.example.com subdomains through Cloudflare to your worker.
wrangler deploy -c worker/wrangler.jsonSet the TRAFORO_BASE_DOMAIN environment variable to point the CLI at your instance:
export TRAFORO_BASE_DOMAIN=example.com
traforo -p 3000
# Tunnel: https://{id}-tunnel.example.comAdd it to your shell profile (~/.zshrc, ~/.bashrc) to make it permanent:
echo 'export TRAFORO_BASE_DOMAIN=example.com' >> ~/.zshrcOr pass it inline for one-off usage:
TRAFORO_BASE_DOMAIN=example.com traforo -- pnpm devWhen using the Node.js API, pass baseDomain directly or rely on the env variable:
import { TunnelClient } from 'traforo/client'
const client = new TunnelClient({
localPort: 3000,
tunnelId: 'my-app',
baseDomain: 'example.com', // or set TRAFORO_BASE_DOMAIN
})
await client.connect()
// https://my-app-tunnel.example.comMIT