portless | Named .localhost URLs for Development

3 min read Original article ↗

Getting Started

Portless replaces port numbers with stable, named .localhost URLs for local development. For humans and agents.

- "dev": "next dev"                  # http://localhost:3000
+ "dev": "portless myapp next dev"   # https://myapp.localhost

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:

portless
# -> runs "dev" script, https://<project>.localhost

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:

portless myapp next dev
# -> https://myapp.localhost

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, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right --port flag and, when needed, a matching --host flag.

Use in package.json

Your scripts stay clean:

{
  "scripts": {
    "dev": "next dev"
  }
}

Then run portless or portless run to go through the proxy.

You can also use portless directly in scripts:

{
  "scripts": {
    "dev": "portless myapp next dev"
  }
}

Subdomains

Organize services with subdomains:

portless api.myapp pnpm start
# -> https://api.myapp.localhost

portless docs.myapp next dev
# -> https://docs.myapp.localhost

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:

# Main worktree (no prefix)
portless run next dev   # -> https://myapp.localhost

# Linked worktree on branch "fix-ui"
portless run next dev   # -> https://fix-ui.myapp.localhost

Put portless run in your package.json once and it works everywhere. The main checkout uses the plain name, each worktree gets a unique subdomain. No collisions, no --force.

Use --name to override the inferred base name while keeping the worktree prefix:

portless run --name myapp next dev   # -> https://fix-ui.myapp.localhost

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:

portless proxy start --tld test
portless myapp next dev
# -> https://myapp.test

The proxy auto-syncs /etc/hosts for route hostnames, so .test and other dev hostnames resolve on your machine.

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.

Browser (myapp.localhost) -> proxy (port 443) -> App (random port)

Requirements

  • Node.js 20+
  • macOS, Linux, or Windows