Getting Started
Portless replaces port numbers with stable, named .localhost URLs for local development. For humans and agents.
Install
Global (recommended):
Or as a project dev dependency:
portless is pre-1.0. When installed per-project, different contributors may run different versions. The state directory format may change between releases, which can require re-running
portless trust.
Run your app
Just run portless. It reads the "dev" script from package.json and runs it through the proxy:
The app name is inferred from package.json, git root, or directory name. Use a portless.json to override (e.g. { "name": "myapp" } for https://myapp.localhost).
You can also run with an explicit command:
HTTPS with HTTP/2 is enabled by default. On first run, portless generates a local CA, trusts it, and binds port 443 (auto-elevates with sudo on macOS/Linux). Use --no-tls for plain HTTP.
The proxy auto-starts when you run an app. A random port (4000--4999) is assigned via the PORT environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore PORT (Vite, VitePlus, VitePress, Astro, React Router, Angular, Expo, React Native, Wrangler), portless auto-injects the right --port flag and, when needed, a matching --host flag (--ip for Wrangler). Injection reaches through a package script whose command starts with the framework or a known runner ("dev": "vite", "dev": "bunx vite"). Only the framework's server commands get the flags (dev, serve, preview, start, a bare vite, or vite [root]); a command that does not serve, such as vite build, vite optimize, vp test or astro check, rejects them and is left alone. Expo connection modes (--localhost, --lan, --tunnel) are preserved while the assigned port is still injected. A script portless cannot classify is left alone too: a flag before the subcommand on a CLI whose flag grammar it does not track (vp --mode dev build). Portless also leaves a script alone when appending flags to it would not work: a compound command (&&, |, ;), a trailing # comment, its own -- option terminator, an env prefix (NODE_ENV=production vite), delegation to another script ("dev": "npm run dev:vite"), or runner flags before the script name (bun run --bun dev). Those keep their own port, so set it in the script yourself.
Use in package.json
Your scripts stay clean:
Then run portless or portless run to go through the proxy.
You can also use portless directly in scripts:
Subdomains
Organize services with subdomains:
Git Worktrees
portless run automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain so each worktree gets its own URL without any config changes:
Put portless run in package.json to apply these naming rules in each checkout.
Use --name to override the inferred base name while keeping the worktree prefix:
Use --prefix <label> (or PORTLESS_PREFIX) to set the label yourself, with "" for none. See Configuration to change the label style or the whole hostname.
Custom TLD
By default, portless uses .localhost which auto-resolves to 127.0.0.1 in most browsers. If you prefer a different TLD (e.g. .test), use --tld:
The proxy auto-syncs /etc/hosts for route hostnames, so .test and other dev hostnames resolve on your machine. If a route hostname will not resolve, the command that registered it warns instead of failing silently and points you to portless hosts sync. Note that .localhost names resolve without a hosts entry on current macOS and Linux, so a skipped write is often invisible; a custom TLD needs the entry.
Repeat --tld to serve the same app names under multiple TLDs from one proxy:
When multiple TLDs are configured, PORTLESS_URL uses the first TLD. PORTLESS_TLD accepts the same comma separated list format, e.g. PORTLESS_TLD=localhost,test.
The --tld value accepts a lowercase DNS name such as dev.example.com, so a domain you own can be used as the "TLD":
This gives local URLs the same structure as production, which keeps OAuth redirect URIs, cross-subdomain cookies, and host-based routing working the same way in both environments. Strict OAuth providers such as Google and Apple reject .localhost and .test redirect URIs but accept a real domain, so https://myapp.dev.example.com/api/auth/callback/google works as a redirect URI. Each label must follow DNS rules: lowercase letters, digits, and interior hyphens, with at most 63 characters per label and 253 characters total. The full hostname (app.TLD) is also subject to the 253-character limit.
A custom TLD is reachable only from the machine running the proxy, because outside LAN mode the proxy binds only to loopback. LAN mode serves apps under .local and ignores a custom --tld, so the two can't be combined.
Recommended: .test (IANA-reserved, no collision risk). Avoid .local (conflicts with mDNS/Bonjour) and .dev (Google-owned, forces HTTPS via HSTS).
How it works
Portless runs an HTTPS reverse proxy on port 443 by default. Each app registers a route mapping its hostname to an assigned port. Requests to https://<name>.localhost are proxied to the app.
The proxy listens only on the IPv4 and IPv6 loopback addresses, 127.0.0.1 and ::1, by default. Network interfaces are exposed only when LAN mode is explicitly enabled.
When portless itself returns an error (404 for an unknown host, 421 for a misdirected request, 502 when the app is unreachable, 508 for a request loop), browsers get an HTML page. Any client whose Accept header does not include text/html, such as curl or fetch, gets a small JSON body instead, for example {"error":"bad_gateway","upstream":"myapp.localhost","message":"..."}. Responses from your own app are forwarded unchanged. Hostnames outside the portless TLDs, such as a site a DNS blocklist points at 127.0.0.1, get 421 Misdirected Request instead of the app list.
Proxy between portless apps
If your frontend dev server proxies API requests to another portless app, make the dev server proxy rewrite the Host header. Without it, portless routes the request back to the frontend in a loop, and responds with 508 Loop Detected and a message pointing to this fix.
Vite (vite.config.ts):
webpack-dev-server (webpack.config.js):
Requirements
- Node.js 24+
- macOS, Linux, or Windows