- Browser
https://example-app.localhost - Local agentterminates trusted TLS
- Your app
127.0.0.1:3000
Your local apps, by name.
https://example-app.localhost instead of localhost:3000.
Stable names, trusted TLS, and a public URL when you need one.
Point routeup at a port, or let it start your app.
$ brew install mukul-mehta/tap/routeup - Open source — MIT
- Zero telemetry
- Self-hostable server
- Local routes work offline
$ routeup serve example-app --port 3000
route: example-app
local: https://example-app.localhost
targets:
/ http://localhost:3000
press Ctrl-C to stop Serve or run
Two ways to use it.
Point routeup at a port you already run, or let it start your app for you. Same route either way.
Your app keeps its server. routeup keeps the name.
Already running on a port? routeup serve example-app --port 3000 gives it
a stable HTTPS name — no config file, no changes to your app.
Commit a routeup.json — or a routeup block in
package.json — and bare routeup serve is enough.
The route lives in the repo, so teammates get the same one.
{
"name": "example-app",
"targets": [
{ "path": "/", "port": 5173 },
{ "path": "/api", "port": 8080 }
]
} $ routeup serve
route: example-app
local: https://example-app.localhost
targets:
/ http://localhost:5173
/api http://localhost:8080
press Ctrl-C to stop Bare routeup starts your app with its route attached.
routeup assigns a free port, injects the environment, registers the route before launch, and releases everything on exit. This site's own config, verbatim:
PORT- assigned loopback port
HOST- 127.0.0.1
ROUTEUP_LOCAL_URL- https://routeup-website.localhost
ROUTEUP_URL- the public URL, once exposed
Node project? The same block can live in package.json as
"routeup", with a "script" name instead of
"command".
{
"name": "routeup-website",
"command": "pnpm dev --port ${PORT}",
"capture": {
"request": true,
"response": true,
"redact_headers": [
"authorization",
"cookie",
"x-forwarded-for"
]
},
"expose": {
"enabled": true,
"paths": ["/*"]
}
} $ routeup
running: pnpm dev --port $PORT
route: routeup-website
local: https://routeup-website.localhost
public: https://routeup-website.try.routeup.dev
targets:
/ http://localhost:59370 Recipes
Pick a recipe.
Frontend and API behind one origin.
Route paths to different ports on one host. Longest prefix wins, so
/api reaches the API and everything else reaches the app —
same-origin cookies, no CORS config.
{
"name": "example-app",
"targets": [
{ "path": "/", "port": 5173 },
{ "path": "/api", "port": 8080 }
]
} example-app.localhost/ → localhost:5173
example-app.localhost/api/* → localhost:8080 Make the webhook handler public, not the whole app.
expose.paths limits what the public server forwards. Local traffic
still reaches every target — only matching paths cross the tunnel.
{
"name": "example-app",
"port": 8080,
"expose": {
"enabled": true,
"paths": ["/api/webhooks/*"]
}
} Group services under one name.
A dotted name is used literally. A bare one scopes under the project name from
your config, so routeup serve api in the example-app directory
registers api.example-app.
$ routeup serve api.example-app --port 8080
route: api.example-app
local: https://api.example-app.localhost
targets:
/ http://localhost:8080
press Ctrl-C to stop Follow local and public requests in one stream.
Source, method, path, status, duration, request ID. Bodies are not retained unless capture is on.
Logs reference →$ routeup logs example-app --follow
TIME SOURCE ROUTE METHOD PATH STATUS DURATION ID
14:02:01 local example-app GET /api/users 200 12ms req_Kx4mT9pL2a
14:02:03 public example-app POST /api/webhooks/stripe 204 8ms req_Jz6nR2wL8c Capture deliberately, then read it back.
Opt in per route and the request and response are held in memory
until restart, with the headers you name kept out. Grab an id from
routeup logs, then inspect it.
{
"capture": {
"request": true,
"response": true,
"redact_headers": ["authorization", "cookie"]
}
} $ routeup inspect req_abc123
source: public
route: example-app
POST /api/webhooks/github → 200 · 38ms
request 842 bytes X-Github-Event: push
response 14 bytes application/json See what is active before changing anything.
routeup routes lists active names, targets, and owner PIDs.
routeup doctor checks the CA, trust store, port 443, and agent.
$ routeup routes
NAME TARGETS PUBLIC PATHS PID AGE CWD
example-app /:3000 - - 48172 5m ~/code/example-app
$ routeup doctor
[ok] local CA
[ok] system trust store
[ok] port 443
[ok] agent Going public
Reachable from the internet, on demand.
Add --expose — or run routeup expose next to
a route that's already up — and requests travel through a routeup
server and back down an outbound tunnel to your machine. Your app
still listens only on localhost.
No token nothing to sign up for
$ routeup serve example-app --port 8080 --expose
route: example-app
local: https://example-app.localhost
public: https://example-app.try.routeup.dev
expose: all paths An ephemeral URL under try.routeup.dev, live while the command runs. Good for a quick webhook or a link to your phone.
With a token your own namespace
$ routeup setup --token sk_routeup_…
$ routeup serve example-app --port 8080 --expose
public: https://example-app.mukul.routeup.dev Save a token once and every route gets a stable name under your namespace — reuse it in Stripe, an OAuth app, or a bookmark. Self-host the server and mint your own tokens too.
Getting started
From install to first route.
One binary and a one-time CA trust. Local routes need no account, no token, and no server contact.
Installation details →- 01
Install and set up.
$ brew install mukul-mehta/tap/routeup $ routeup setup Public server URL for `expose` (leave empty for default, 'none' to stay local) [https://edge.routeup.dev]: Token for https://edge.routeup.dev (blank to keep current): certificate authority: created (~/.routeup/ca.crt) trusting the certificate system-wide (asks for your password)... certificate: trusted setting up port 443 (asks for your password)... port 443: ready server: saved (https://edge.routeup.dev) agent: startedNot on Homebrew? There's an install script and release binaries — setup is the same either way.
- 02
Serve something, then open it.
$ routeup serve example-app --port 3000 route: example-app local: https://example-app.localhosthttps://example-app.localhostTrusted TLS, no warning page. The name survives restarts and port changes.
Why routeup
What plain localhost can't do.
Four gaps that show up in almost every project.
- [names]
Names, not port numbers.
You run three or four things at once.
3000,8080,5173— the numbers don't say what they are, and they move when something grabs the port first.example-app.localhostdoesn't. - [https]
Real HTTPS, locally.
Secure cookies, service workers, and WebAuthn only run over HTTPS. Plain
http://localhostdoesn't have it — every route gets a certificate your OS already trusts. - [public]
A public URL that stays put.
A Stripe webhook, a teammate, your phone on the same Wi-Fi — they can all reach one address that's the same every time you start up.
- [origin]
One origin for the whole stack.
Put the frontend and API under the same host, and cookies, sessions, and CORS behave the way they will in production.
The same local target, two paths to reach it.
- Internet client
https://example-app.try.routeup.dev - Routeup serverterminates public TLS
- Outbound tunnelencrypted back to your agent
- Your app
127.0.0.1:3000
The boundaries
Local first, public by choice.
The CLI, the local agent, and the public server ship as one MIT-licensed binary. Local routes work offline with nothing to sign up for. Public exposure is opt-in, and the server can be the hosted one or your own.
- Local means localNo account, token, DNS, or server request for
*.localhost. - Capture is opt-inRequest bodies are not retained unless a route explicitly enables capture.
- Ctrl-C cleans upStopping a command releases its route, child process, and tunnel with it.
- Zero telemetryNo metrics, beacons, or crash reports. The binary talks to a server only when you expose a route.