portless

CLI Tool

vercel-labs/portless

Replace port numbers with stable named local URLs for dev.

Overview

Portless is a command-line tool that gives local development servers stable, named HTTPS URLs like myapp.localhost instead of random ports. It auto-starts a proxy, handles TLS certificate trust, and injects port flags for common frameworks. Supports subdomains, monorepos, turbo pipelines, and git worktrees.

README Preview

# portless\n\nReplace port numbers with stable, named .localhost URLs for local development. For humans and agents.\n\n```diff\n- "dev": "next dev"                  # http://localhost:3000\n+ "dev": "portless run next dev"     # https://myapp.localhost\n```\n\n## Install\n\n**Global (recommended):**\n\n```bash\nnpm install -g portless\n```\n\n**Or as a project dev dependency:**\n\n```bash\nnpm install -D portless\n```\n\n> 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`.\n\n## Run your app\n\n```bash\nportless myapp next dev\n# -> https://myapp.localhost\n```\n\nHTTPS 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.\n\nThe 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, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag. 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 w

FAQ (5)

Troubleshooting
How do I fix an unhandled 'read ECONNRESET' crash in a Node.js proxy when the backend restarts after a keep-alive request?

This affects portless 0.10.3–0.15.6. The proxy forwards the client's Connection: keep-alive header to the backend, but the backend socket has no agent or error listener after the response completes. If the backend restarts and resets the socket, Node emits an unhandled ECONNRESET and exits.

Fix the normal HTTP proxy path by removing hop-by-hop headers before calling http.request():

for (const name of String(proxyReqHeaders.connection || '').split(',')) {
  const header = name.trim().toLowerCase();
  if (header) delete proxyReqHeaders[header];
}

for (const header of HOP_BY_HOP_HEADERS) {
  delete proxyReqHeaders[header];
}

Typical hop-by-hop headers are:

const HOP_BY_HOP_HEADERS = [
  'connection',
  'keep-alive',
  'proxy-authenticate',
  'proxy-authorization',
  'te',
  'trailer',
  'transfer-encoding',
  'upgrade',
];

Do not apply this to the WebSocket upgrade path: it intentionally sends Connection: Upgrade and already attaches socket error listeners.

As a temporary workaround, attach a no-op error handler to the socket returned by createLoopbackConnection():

createConnection: () => {
  const socket = createLoopbackConnection(route.port);
  socket.on('error', () => {});
  return socket;
}

Upgrade portless to a release containing the fix for issue #434 when available.

GitHub Issue #434
Troubleshooting
How do I fix Portless wildcard subdomains routing to the wrong parent app?

With PORTLESS_WILDCARD=1, Portless 0.15.3/0.15.5 uses routes.find() for wildcard fallback, so the first matching parent in routes.json wins instead of the most specific one. Fix findRoute to select the longest matching parent hostname:

routes
  .filter((r) => hostname.endsWith("." + r.hostname.toLowerCase()))
  .sort((a, b) => b.hostname.length - a.hostname.length)[0]

For example, admin.api.acme.localhost then matches api.acme.localhost before acme.localhost. As a temporary workaround, place the more specific route first in routes.json, but this is order-dependent and less reliable than the patch. A fixed release should include the PR titled "fix(proxy): pick the most specific parent route in wildcard fallback".

GitHub Issue #380
Troubleshooting
How to fix Portless overwriting /etc/hosts and removing localhost and other system entries?

Upgrade to the Portless release containing PR #424 (fixes issue #419). In affected versions, readHostsFile() returned an empty string when /etc/hosts could not be read, so syncHostsFile() wrote only the Portless-managed block. The fix makes sync and cleanup abort when the initial read fails and returns failure if the post-write verification read fails.

To restore a damaged hosts file, edit it with privileges:

sudo nano /etc/hosts

Restore at least the standard loopback entries, for example:

127.0.0.1	localhost
127.0.0.1	myhost
::1		localhost ip6-localhost ip6-loopback
ff02::1		ip6-allnodes
ff02::2		ip6-allrouters

If a backup exists, restore it instead:

sudo cp /etc/hosts.bak /etc/hosts

As a temporary workaround, avoid running the affected Portless version when /etc/hosts may be locked, unavailable, or unreadable, and ensure the process has the permissions required to read and write the file.

GitHub Issue #419
Troubleshooting
Why does Portless assign an occupied port and route readiness checks to the wrong process on macOS?

This is caused by Portless 0.12.0 probing free ports with a hostless net.createServer().listen(port) bind while proxying to 127.0.0.1:<port>. On macOS, the probe can succeed even when another process is already listening on IPv4 loopback.

Immediate workaround:
1. Find the stale listener:
lsof -nP -iTCP:<port> -sTCP:LISTEN
2. Stop it with kill <PID>, or explicitly assign a known-free port with PORT=<free-port>.
3. Verify the port is free on both loopback stacks before restarting:
lsof -nP -iTCP@127.0.0.1:<port> -sTCP:LISTEN
lsof -nP -iTCP@[::1]:<port> -sTCP:LISTEN

Permanent fix: upgrade to a Portless release containing the fix for issue #288 (PR #441, building on #302). The corrected port probe checks candidate ports on the relevant local addresses, including 127.0.0.1, ::1, and 0.0.0.0, instead of using a hostless bind.

GitHub Issue #288
Troubleshooting
How do I fix portless using Homebrew Node 25 instead of my asdf, nvm, fnm, or mise Node version?

This happens because affected portless versions prepend path.dirname(process.execPath) to child-process PATH, so the Node running portless shadows the version selected by your version manager.

Upgrade portless to the release containing the fix for issue #241. The fix places the user's existing PATH before portless's own Node directory (or only injects that directory where required on Windows), so version-manager shims resolve first.

brew upgrade portless
# or, if installed with npm under your managed Node:
npm update -g portless

portless myapp sh -c 'node --version'

Temporary workaround if you cannot upgrade: remove the Homebrew-installed portless and install it with the Node managed by asdf/nvm/fnm/mise:

brew uninstall portless
nvm use 24          # or: fnm use 24
# asdf: asdf shell nodejs 24.13.0
# mise: mise use -g node@24
npm install -g portless
portless myapp sh -c 'node --version'

GitHub Issue #241