portless Documentation

Replace port numbers with stable, named local URLs. For humans and agents.

12,733 stars TypeScript TypeScript 1 file ยท ~23,602 tokens
RAW Doc

Technical Documentation: vercel-labs/portless

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

Overview (`README.md`)

portless

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




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

Install

Global (recommended):

bash
npm install portless

Or as a project dev dependency:

bash
npm install -D portless

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

bash
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, 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 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.

When auto-starting, portless reuses the configuration (port, TLS, TLDs) from the most recent proxy run, so a restart or reboot does not silently revert to defaults. Explicit env vars (PORTLESS_PORT, PORTLESS_HTTPS, etc.) always take priority.

Portless stores per-user state in ~/.portless. When the proxy runs under sudo, it resolves this path from the invoking user's home so the proxy and unprivileged app processes share the same route registrations.

In non-interactive environments (no TTY, or CI=1), portless exits with a descriptive error instead of prompting, so task runners like turborepo and CI scripts fail early with a clear message.

Configuration

Bare portless works out of the box. It runs the "dev" script from package.json through the proxy, inferring the app name from the package name, git root, or directory:

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

Use an optional portless.json to override defaults:

json
{ "name": "myapp" }
bash
portless        # -> runs "dev" script, https://myapp.localhost

The script defaults to "dev". The name is inferred from package.json if not set in config.

Monorepo

One portless.json at the repo root covers all workspace packages. Portless discovers packages from pnpm-workspace.yaml, or the "workspaces" field in package.json (npm, yarn, bun):

json
{
  "apps": {
    "apps/web": { "name": "myapp" },
    "apps/api": { "name": "api.myapp" }
  }
}
bash
portless        # from repo root: starts all workspace packages with a "dev" script
cd apps/web && portless   # start just one package

The apps map is optional and only needed for name overrides. Packages not listed still auto-discover with names inferred from their package.json.

Without an apps map, hostnames follow the ..localhost convention. The project name comes from the most common npm scope across workspace packages (e.g. @myorg/web and @myorg/api produce myorg), falling back to the workspace root directory name. If a package's short name matches the project name, it gets the bare .localhost without duplication.

Config fields

Field Type Default Description
name string inferred Base app name. Worktree prefix still applies.
script string "dev" Name of a package.json script to run.
appPort number auto Fixed port for the child process.
proxy boolean auto Whether to route through the proxy. Auto-detected.
apps object Overrides for workspace packages, keyed by relative path.
turbo boolean true Set false to use direct spawning instead of turborepo.

package.json "portless" key

Instead of a separate portless.json, you can add a "portless" key to your package.json. A string value is shorthand for setting the name:

json
{
  "name": "@myorg/web",
  "portless": "myapp"
}

An object supports all per-app fields (name, script, appPort, proxy):

json
{
  "name": "@myorg/web",
  "portless": { "name": "myapp", "script": "dev:app" }
}

The package.json "portless" key takes precedence over portless.json app entries but is overridden by CLI flags.

--script flag

Override the default script for a single invocation:

bash
portless --script start       # run "start" instead of "dev"
portless --script test        # run "test" instead of "dev"

Turborepo

To use portless with turborepo, put portless as the dev script and the real command in a separate script:

json
{
  "scripts": {
    "dev": "portless",
    "dev:app": "next dev"
  },
  "portless": { "name": "myapp", "script": "dev:app" }
}

Turbo runs each package's dev script, which invokes portless. Portless reads the config, detects the package manager, and runs pnpm run dev:app (or yarn/bun/npm) through the proxy. No changes to turbo.json or turbo.jsonc are needed.

pnpm dev at the root works through turbo as usual. People without portless can run pnpm run dev:app directly.

When portless runs from a workspace root, it uses the existing Turbo integration to preserve task ordering when either turbo.json or turbo.jsonc is readable. Set "turbo": false in the root portless configuration to use direct spawning instead.

Use in package.json

You can still use portless in package.json scripts:

json
{
  "scripts": {
    "dev": "portless run next dev"
  }
}

With a portless.json, you can simplify to:

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

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

When you press Ctrl+C, portless forwards the interrupt and waits for the command's process tree to
exit. Press Ctrl+C again to forward another interrupt. Any remaining descendants are terminated
after a short grace period.

Subdomains

Organize services with subdomains:

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

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

By default, only explicitly registered subdomains are routed (strict mode). Use --wildcard when starting the proxy to allow any subdomain of a registered route to fall back to that app (e.g. tenant1.myapp.localhost routes to the myapp app without extra registration). When several registered routes are parents of the host, the most specific one wins (admin.api.myapp.localhost routes to api.myapp, not myapp).

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:

bash
# 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

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

bash
portless run --name myapp 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.

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:

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

The proxy auto-syncs /etc/hosts for route hostnames (including .test), so those domains resolve on your machine.

Repeat --tld to serve the same app names under multiple TLDs from one proxy:

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

When multiple TLDs are configured, PORTLESS_URL uses the first TLD. PORTLESS_TLD also accepts a comma separated list, e.g. PORTLESS_TLD=localhost,test.

Recommended: .test (IANA-reserved, no collision risk). Avoid .local (conflicts with mDNS/Bonjour) and .dev (Google-owned, forces HTTPS via HSTS).

Multi-segment TLDs

The --tld value accepts a lowercase DNS name (one or more dot-separated labels, no trailing dot), 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:

bash
portless proxy start --tld dev.example.com
portless myapp next dev
# -> https://myapp.dev.example.com

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 DNS limit.

The proxy auto-syncs /etc/hosts for registered hostnames, so myapp.dev.example.com resolves to 127.0.0.1 on your machine. This is a loopback-only setup: outside LAN mode the proxy binds only to 127.0.0.1 and ::1 (see below), so a custom TLD is reachable only from the machine running the proxy. Reaching the proxy from other devices requires LAN mode (--lan), but LAN mode serves apps under the .local TLD and ignores a custom --tld, so the two cannot be combined today.

Strict OAuth providers (Google, 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.

How it works

mermaid
flowchart TD
    Browser["Browser<br>myapp.localhost"]
    Proxy["portless proxy<br>(port 80 or 443)"]
    App1[":4123<br>myapp"]
    App2[":4567<br>api"]

    Browser --> Proxy
    Proxy --> App1
    Proxy --> App2
  1. Start the proxy: auto-starts when you run an app, or start explicitly with portless proxy start
  2. Run apps: portless <name> <command> assigns a free port and registers with the proxy
  3. Access via URL: https://<name>.localhost routes through the proxy to your app

Outside LAN mode, the proxy and its HTTP redirect listener bind only to the IPv4 and IPv6 loopback addresses, 127.0.0.1 and ::1. They do not accept connections through LAN, VPN, or other network interfaces.

When portless itself returns an error (404 for an unknown host, 502 when the app is unreachable, 508 for a request loop), browsers get a branded HTML page. Any client whose Accept header does not include text/html, such as curl or fetch, gets a small JSON body instead, e.g. {"error":"bad_gateway","upstream":"myapp.localhost","message":"..."}. Responses from your own app are forwarded unchanged.

HTTP/2 + HTTPS

HTTPS with HTTP/2 is enabled by default. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers that serve many unbundled files (Vite, Nuxt, etc.). HTTP/2 multiplexes all requests over a single connection.

WebSockets work over both protocol versions, so dev server HMR (Next.js, Vite, etc.) works through the proxy: HTTP/1.1 Upgrade requests are forwarded as-is, and WebSockets opened over an HTTP/2 connection use extended CONNECT (RFC 8441).

On first run, portless generates a local CA and adds it to your system trust store. No browser warnings. No manual setup.

bash
# Use your own certs (e.g., from mkcert)
portless proxy start --cert ./cert.pem --key ./key.pem

# Disable HTTPS (plain HTTP on port 80)
portless proxy start --no-tls

# If you skipped the trust prompt on first run, trust the CA later
portless trust

On Linux, portless trust supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and openSUSE (via update-ca-certificates or update-ca-trust). On Windows, it uses certutil to add the CA to the system trust store. On WSL, it updates both the Linux trust store and the Windows current-user Root store so Windows browsers trust portless HTTPS certificates.

Start at OS startup

Install the proxy as an OS startup service so clean HTTPS URLs are available after reboot without starting the proxy from a terminal:

bash
portless service install
portless service install --lan
portless service install --wildcard
PORTLESS_STATE_DIR=~/.portless-lan PORTLESS_LAN=1 portless service install
portless service status
portless service uninstall

The service uses portless defaults unless install options or PORTLESS_* environment variables are provided: HTTPS on port 443 with .localhost names. service install accepts the proxy options you would use with proxy start, including --port, --no-tls, --lan, --ip, --tld, --wildcard, --cert, and --key. Use --state-dir or PORTLESS_STATE_DIR= to choose where service state and logs are written.

The chosen service configuration is written into launchd, systemd, or Task Scheduler and reused after reboot. portless service status reports the installed port, HTTPS mode, TLDs, LAN mode, wildcard mode, and state directory. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. portless clean automatically removes the service.

LAN mode

bash
portless proxy start --lan
portless proxy start --lan --https
portless proxy start --lan --ip 192.168.1.42

--lan explicitly binds the proxy to the IPv4 and IPv6 unspecified addresses, 0.0.0.0 and ::, and switches to mDNS discovery. This makes services available as <name>.local to devices on the same network. Portless auto-detects your LAN IP and follows Wi-Fi/IP changes automatically, but you can pin another address with --ip <address> or by exporting PORTLESS_LAN_IP. Set PORTLESS_LAN=1 in your shell (0/1 boolean) to make LAN mode the default whenever the proxy starts.

Portless remembers LAN mode via proxy.lan, so if you stop a LAN proxy and start it again, it stays in LAN mode. All proxy settings (port, TLS, TLDs, LAN) are persisted and reused on auto-start unless overridden by explicit flags or env vars. Use PORTLESS_LAN=0 for one start to switch back to .localhost mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first.

LAN mode depends on the system mDNS tools that portless already spawns: macOS ships with dns-sd, while Linux uses avahi-publish-address from avahi-utils (install via sudo apt install avahi-utils or your distroโ€™s equivalent). If the command is missing or your network isnโ€™t reachable, portless proxy start --lan prints the relevant error and exits.

Framework notes

  • Next.js: add your .local hostnames to allowedDevOrigins:

    js
    // next.config.js
    module.exports = {
      allowedDevOrigins: ["myapp.local", "*.myapp.local"],
    };
  • Expo / React Native: portless always injects --port. React Native also gets --host 127.0.0.1. Expo gets --host localhost outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing --host or HOST.

Tailscale sharing

Share your dev server with teammates on your Tailscale network:

bash
portless myapp --tailscale next dev
# -> https://myapp.localhost           (local)
# -> https://devbox.yourteam.ts.net    (tailnet)

Each --tailscale app is root-mounted on its own Tailscale HTTPS port, so no framework basePath configuration is needed. The first app gets port 443, subsequent apps get 8443, 8444, etc.

bash
portless myapp --tailscale next dev     # -> https://devbox.ts.net
portless api --tailscale pnpm start     # -> https://devbox.ts.net:8443

Use --funnel to expose your dev server to the public internet via Tailscale Funnel:

bash
portless myapp --funnel next dev
# -> https://devbox.yourteam.ts.net    (public)

Tailscale HTTPS certificates must be enabled before --tailscale or --funnel can register HTTPS URLs. Funnel must also be enabled for the tailnet and node before --funnel can register the public URL. If either setting is missing, portless exits before starting the child process.

Set PORTLESS_TAILSCALE=1 in your shell profile or .env to share every app by default. portless list shows both local and tailnet URLs. Tailscale serve registrations are cleaned up automatically when the app exits.

Requires the Tailscale CLI to be installed and connected (tailscale up), with Tailscale HTTPS certificates enabled.

ngrok sharing

Expose your dev server to the public internet with ngrok:

bash
portless myapp --ngrok next dev
# -> https://myapp.localhost           (local)
# -> https://abc123.ngrok.app          (public)

Set PORTLESS_NGROK=1 in your shell profile or .env to enable ngrok by default when portless runs an app. portless list shows both local and ngrok URLs. The ngrok tunnel is cleaned up automatically when the app exits.

Requires the ngrok CLI to be installed and authenticated. If ngrok reports an authentication error, run ngrok config add-authtoken <token> and try again.

Cloudflare sharing

Expose your dev server to the public internet with a Cloudflare quick tunnel. No Cloudflare account is needed:

bash
portless myapp --cloudflare next dev
# -> https://<random>.trycloudflare.com (public)

Set PORTLESS_CLOUDFLARE=1 to enable it by default. portless list shows the Cloudflare URL, and the app gets it as PORTLESS_CLOUDFLARE_URL. You can combine it with --ngrok, --tailscale or --funnel. The tunnel stops when the app exits. Quick tunnels do not support Server-Sent Events, allow at most 200 in-flight requests, and get a new URL on every run. Requires the cloudflared CLI on your PATH.

Commands

text
/* Detailed source-code truncated for AI context efficiency. */

Options

text
/* Detailed source-code truncated for AI context efficiency. */

Environment variables

text
/* Detailed source-code truncated for AI context efficiency. */

Reserved names: run, get, alias, hosts, list, doctor, trust, clean, prune, proxy, and service are subcommands and cannot be used as app names directly. Use portless run <cmd> to infer the name from your project, or portless --name <name> <cmd> to force any name including reserved ones.

Uninstall / reset

To remove portless data from your machine (proxy state under ~/.portless and the system state directory, the local CA from the OS trust store when portless installed it, and the portless block in /etc/hosts):

bash
portless clean

macOS/Linux may prompt for sudo. Custom certificate paths passed with --cert and --key are not deleted. If trust-store removal fails, portless retains its CA certificate and key so a later portless clean can safely retry.

Safari / DNS

.localhost subdomains auto-resolve to 127.0.0.1 in Chrome, Firefox, and Edge. Safari relies on the system DNS resolver, which may not handle .localhost subdomains on all configurations.

If Safari can't find your .localhost URL:

bash
portless hosts sync    # Reconcile current routes with /etc/hosts
portless hosts clean   # Clean up later

Auto-syncs /etc/hosts for route hostnames by default (.localhost, custom TLDs, LAN .local). Set PORTLESS_SYNC_HOSTS=0 to disable. If a route hostname will not resolve, the command that registered it warns and points you to portless hosts sync.

Manual sync reconciles portless-managed entries with current routes and removes stale entries when there are no routes. It requires a successful initial hosts-file read before writing and verifies each write. A read or verification failure follows the normal sync error path.

Troubleshooting

Run portless doctor to inspect local health without changing state. It checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA trust, hostname resolution, and LAN mode prerequisites, then prints suggested fixes.

Proxying Between Portless Apps

If your frontend dev server (e.g. Vite, webpack) proxies API requests to another portless app, make sure the proxy rewrites the Host header. Without this, portless routes the request back to the frontend in an infinite loop.

Vite (vite.config.ts):

ts
server: {
  proxy: {
    "/api": {
      target: "https://api.myapp.localhost",
      changeOrigin: true,
      ws: true,
    },
  },
}

webpack-dev-server (webpack.config.js):

js
devServer: {
  proxy: [{
    context: ["/api"],
    target: "https://api.myapp.localhost",
    changeOrigin: true,
  }],
}

Portless automatically sets NODE_EXTRA_CA_CERTS in child processes so Node.js trusts the portless CA. If you run a separate Node.js process outside portless, point it at the CA manually: NODE_EXTRA_CA_CERTS=~/.portless/ca.pem. Alternatively, use --no-tls for plain HTTP.

Portless detects this misconfiguration and responds with 508 Loop Detected along with a message pointing to this fix.

Development

This repo is a pnpm workspace monorepo using Turborepo. The publishable package lives in packages/portless/.

Use Node.js 24+ and pnpm 11 for repository development. The .node-version file pins the Node major for version managers.

bash
pnpm install          # Install all dependencies
pnpm build            # Build all packages
pnpm test             # Run tests
pnpm test:coverage    # Run tests with coverage
pnpm lint             # Lint all packages
pnpm type-check       # Type-check all packages
pnpm format           # Format all files with Prettier

Requirements

  • Node.js 24+
  • macOS, Linux, or Windows
  • Tailscale CLI (optional, for --tailscale and --funnel)
  • ngrok CLI (optional, for --ngrok)
  • cloudflared CLI (optional, for --cloudflare)

Overview (`apps/docs/README.md`)

Portless documentation

Author the six public pages in content/docs. The shared content feeds Geistdocs, native search, WebMCP, Markdown, and the existing docs chat backend.

Dependency policy

The migration checked published versions on September 17, 2026. Geistdocs 2.3.1 was the latest release, published at 14:23 UTC that day. The repository's minimumReleaseAge: 2880 requires releases to be at least 48 hours old. pnpm selected Geistdocs 2.2.0, published September 15 at 14:53 UTC, without adding an exception or using an unreleased package.

Next.js is pinned to 16.3.4. patches/[email protected] at the repository root contains only two substitutions, changing setHeader to appendHeader for Vary in the CommonJS and ESM app-page runtime templates. This preserves the documentation negotiation headers alongside Next's RSC headers without modifying global Node response prototypes. Recheck both runtime templates and production HTTP headers when upgrading Next.

Response handling

RSC and prefetch requests bypass Markdown negotiation but retain the unprefixed language rewrite. Documentation responses use private, no-store caching, including explicit Markdown routes, with Vary covering representation-detection headers. The /en prefix redirects permanently to the unprefixed URL without setting a locale cookie.

Verification

Use pnpm --filter @portless/docs build, pnpm --filter @portless/docs type-check, pnpm --filter @portless/docs test and pnpm --filter @portless/docs test:routes from the repository root. For a normal production build, leave VERCEL_ENV unset or set it to production. Preview builds retain noindex while all page and Markdown canonicals use https://portless.sh.

See the regression guide for production/preview commands, frozen content baselines and server cleanup. Verification records results and deployment limits. The route suite runs in Linux CI after the build.


Overview (`examples/google-oauth/README.md`)

Google OAuth with portless

Google OAuth rejects .localhost subdomains as redirect URIs. This example shows how to use portless with a custom TLD to get a domain that Google accepts.

Why `.localhost` fails

Google's OAuth credentials page validates redirect URIs against a bundled copy of the Public Suffix List. .localhost is not in their copy, so subdomains like myapp.localhost:3000 are rejected with:

  • "must end with a public top-level domain (such as .com or .org)"
  • "must use a domain that is a valid top private domain"

Plain localhost works because Google hardcodes it in a whitelist, but subdomains do not.

The fix: use any valid TLD

Any TLD in the Public Suffix List works. portless lets you set a custom TLD with --tld:

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

.dev is a real gTLD (owned by Google). It requires HTTPS because it's HSTS-preloaded, which portless handles automatically.

Using a bare TLD like .dev means your local domain (oauth-test.dev) could collide with a real domain someone else owns. For safer local development, use a subdomain of a domain you control:

bash
portless proxy start --tld dev
portless oauth-test.local.ctate next dev
# -> https://oauth-test.local.ctate.dev

Since you own ctate.dev, nothing under local.ctate.dev will conflict with real internet traffic. This works for teams too: set a wildcard DNS record (*.local.yourcompany.dev -> 127.0.0.1) so every developer gets local resolution without /etc/hosts.

Setup

1. Install portless

bash
npm install portless

2. Start the proxy

bash
portless proxy start --tld dev

Portless defaults to HTTPS on port 443 (auto-elevates with sudo), so URLs don't need a port number.

3. Create a Google OAuth client

  1. Go to Google Cloud Console > Credentials
  2. Click "Create Credentials" > "OAuth client ID"
  3. Choose "Web application"
  4. Add your domain to "Authorized JavaScript origins" (e.g. https://oauth-test.dev)
  5. Add the callback URL to "Authorized redirect URIs" (e.g. https://oauth-test.dev/api/auth/callback/google)
  6. Copy the Client ID and Client Secret

4. Configure the example

bash
cd examples/google-oauth
cp .env.example .env

Edit .env with your credentials:

text
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
NEXTAUTH_URL=https://oauth-test.dev

Generate a secret:

bash
openssl rand -base64 32

5. Install and run

bash
pnpm install
pnpm dev

This runs portless oauth-test next dev, which serves the app at https://oauth-test.dev.

6. Test

Open https://oauth-test.dev in your browser and click "Continue with Google".

Alternative: plain localhost for the callback

If you only need Google OAuth and don't want to change TLDs, you can add http://localhost:3000/api/auth/callback/google as an additional redirect URI. Google allows plain localhost with any port. Configure NextAuth to use it:

text
NEXTAUTH_URL=http://localhost:3000

The downside is you lose the portless benefits (named URLs, no port conflicts) for the OAuth callback flow.


Overview (`apps/docs/tests/README.md`)

Docs regression verification

From the repository root:

sh
pnpm --filter @portless/docs test
pnpm --filter @portless/docs test:routes

The unit suite runs separately from the Node built-in HTTP suite. No test dependencies are added. The HTTP runner requires an existing production build, starts its own next start process on an ephemeral 127.0.0.1 port, and terminates it after success, failure, SIGINT or SIGTERM. Requests do not follow redirects implicitly.

To build and verify production indexing explicitly:

sh
VERCEL_ENV=production pnpm --filter @portless/docs build
VERCEL_ENV=production DOCS_EXPECT_NOINDEX=0 pnpm --filter @portless/docs test:routes

To verify preview indexing, build and run with matching environment values:

sh
VERCEL_ENV=preview pnpm --filter @portless/docs build
VERCEL_ENV=preview DOCS_EXPECT_NOINDEX=1 pnpm --filter @portless/docs test:routes

A preview build replaces the local production build. Restore it with the production build command before running the default production assertions. DOCS_EXPECT_NOINDEX only changes test expectations; it does not configure Next.js or rebuild the app.

Frozen migration baseline

fixtures/docs-baseline.json records commit 05d8a9a791052ae9322c24ebeafc27f596ceb4d8. It contains the six original routes, exact metadata expectations, original MDX SHA-256 hashes, cleaned Markdown body hashes, SSR samples and heading tuples [level, text, id]. Tests read this fixture without invoking git or regenerating expectations from the implementation.

At this commit, src/mdx-components.tsx renders headings without assigning IDs, and next.config.mjs has no slug plugin. There is no old slugify algorithm or generated heading-ID contract to reproduce. Heading text and levels come from the original MDX, excluding fenced code blocks. The fixture freezes the migration's GitHub-style anchors, including punctuation removal and duplicate suffixes, for all 144 section headings. The six H1 titles are checked separately. Footer headings are not part of this contract.

When intentionally changing documentation, review the source and update only the affected fixture values. Do not refresh hashes just to make an unexpected content difference pass. The original MDX hash covers the file reconstructed by replacing migration frontmatter with its original H1; the Markdown hash covers the original cleaned body with its final newline. These six original pages have no import/export statements or decorative JSX divs to strip.

HTTP details

HTML assertions inspect attributes independently of order, parse JSON-LD, and examine SSR text rather than React serialization or error stacks. Next Flight requests include ?_rsc, avoiding Next's cache-key normalization redirect. The browser-navigation case uses node:http because Node fetch overwrites Sec-Fetch-Mode. Font discovery accepts HTML preload tags or HTTP Link preloads.

The suite covers representation negotiation, full Vary inputs, private/no-store including HEAD, canonical redirects, invalid paths, agent indexes, environment-specific indexing, body search, and asset bypass. Known contract failures remain failing assertions rather than skips or implementation workarounds.


Apps Docs Content Docs Index (`apps/docs/content/docs/index.mdx`)


title: "Getting Started"

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

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

Install

Global (recommended):

bash
npm install portless

Or as a project dev dependency:

bash
npm install -D portless

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:

bash
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:

bash
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, 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, or a bare vite); a command that does not serve, such as vite build, vite optimize, vp test or astro check, rejects them and is left alone. 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:

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

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

You can also use portless directly in scripts:

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

Subdomains

Organize services with subdomains:

bash
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:

bash
# 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:

bash
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:

bash
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. 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:

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

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":

bash
portless proxy start --tld dev.example.com
portless myapp next dev
# -> https://myapp.dev.example.com

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. Each label must follow DNS rules: lowercase letters, digits, and interior hyphens, with at most 63 characters per label and 253 characters total.

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.

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

Requirements

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

Apps Docs Package.Json (`apps/docs/package.json`)

{
"name": "@portless/docs",
"version": "0.0.0",
"portless": "portless",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"postinstall": "fumadocs-mdx",
"prebuild": "fumadocs-mdx",
"build": "next build",
"type-check": "fumadocs-mdx && tsc --noEmit",
"start": "next start",
"test": "vitest run src/lib",
"test:routes": "node scripts/test-routes.mjs"
},
"dependencies": {
"@ai-sdk/react": "^3.0.96",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.2",
"@vercel/agent-readability": "0.5.1",
"@vercel/geistdocs": "2.7.5",
"ai": "^6.0.94",
"bash-tool": "^1.3.15",
"clsx": "^2.1.1",
"fumadocs-core": "16.2.2",
"fumadocs-mdx": "14.0.4",
"geist": "^1.7.0",
"just-bash": "^2.10.2",
"next": "16.3.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"shiki": "^4.0.2",
"streamdown": "^2.3.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mdx": "^2",
"@types/node": "^24.12.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
}


Apps Docs Tsconfig.Json (`apps/docs/tsconfig.json`)

{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/.source/": ["./.source/"],
"@/": ["./src/"]
}
},
"include": [
"/*.mdx",
"
/.ts",
"**/
.tsx",
"next-env.d.ts",
".next/types//*.ts",
".next/dev/types/
/*.ts"
],
"exclude": ["node_modules"]
}


Apps Docs Tests VERIFICATION (`apps/docs/tests/VERIFICATION.md`)

Migration verification

Verified locally on September 17, 2026, on feat/portless-geistdocs, based on 05d8a9a791052ae9322c24ebeafc27f596ceb4d8. This records pre-publication validation; no production deployment was performed.

Final checks

All commands ran from the repository root and exited 0:

sh
VERCEL_ENV=preview pnpm --filter @portless/docs build
VERCEL_ENV=preview DOCS_EXPECT_NOINDEX=1 pnpm --filter @portless/docs test:routes
VERCEL_ENV=production pnpm --filter @portless/docs build
VERCEL_ENV=production DOCS_EXPECT_NOINDEX=0 pnpm --filter @portless/docs test:routes
pnpm --filter @portless/docs test
pnpm --filter @portless/docs type-check
git diff --check

Both route runs passed 89/89 with no skips. The unit suite passed 18/18 across three files. The local build was left in production mode. The route runner closes its owned server, including when assertions fail. The Linux CI job now runs the route suite after the repository build and unit tests.

Scoped Biome formatting passed for changed TypeScript, TSX, JavaScript modules and JSON. The repository-wide CI jobs and Windows checks were not executed locally.

Protected contract

The six existing public paths, complete MDX bodies and cleaned Markdown bodies match the frozen pre-migration baseline. HTML checks cover exact titles and descriptions, self-canonical URLs, Markdown alternates, OG/Twitter images, WebSite JSON-LD, one H1 and server-rendered content. All 144 section headings are addressable. The original renderer did not generate IDs: the fixture distinguishes historical heading text from newly frozen anchor expectations.

Negotiation tests cover HTML and Markdown Vary/cache headers, HEAD, quality preferences, social previews, Googlebot, browser navigation, RSC and prefetch. Redirect tests preserve queries without locale cookies. Sitemap, robots, agent indexes, full-body/table search, images, fonts and favicon are covered. Invalid routes return actual 404s.

Failures found and resolved

The first route run passed 80 tests and failed nine. Malformed percent encodings (/% and /%E0%A4%A) caused 500 responses across four representations. The proxy now validates encoding before framework decoding. Native search also missed PORTLESS_SYNC_HOSTS in the configuration table; the supported Fumadocs structure scan now includes table cells, inline code and code blocks. All nine assertions now pass unchanged.

The old missing-Markdown unit assertion expected HTTP 200. It now requires 404, retaining canonical attribution and the readable not-found body.

A browser check found that Escape did not dismiss mobile chat when another dialog layer had prevented the default event. The chat keyboard handler now closes when Escape originates in its own mobile dialog, without closing it for a different dialog's input.

Browser checks

agent-browser against the local production server confirmed native search navigation to /configuration#environment-variables, WebMCP table search and page reading after navigation, desktop chat open/Escape, mobile chat dismissal after the fix, and no horizontal overflow at 390px. The visible theme label switches to dark-theme and persists after reload. After animation frames, the launcher clears the footer theme controls by 24px. No browser errors were reported. These are manual CLI smoke checks, not an automated visual comparison or live LLM test.

Rollout limits

Local tests do not establish unchanged rankings, deployed CDN behavior or field Core Web Vitals. Before and after deployment, run the HTTP suite against the deployment where accessible, verify HTML/Markdown in both cache-warming orders, and monitor Search Console indexing, crawl errors, impressions and clicks. Keep preview noindex enabled and confirm it is absent on production.

The existing chat backend and rate limiter are unchanged. Live model responses were not tested. GitHub CI and production deployment verification remain pending.


Apps Docs Content Docs Changelog (`apps/docs/content/docs/changelog.mdx`)


title: "Changelog"

0.15.10

Bug Fixes

  • Host certificates: Omit invalid sibling wildcard names from two-label host certificates and regenerate cached certificates that contain them, fixing TLS rejection on Apple platforms
  • Proxy error responses: Return JSON for portless-generated 404, 502, and 508 errors when the client does not accept HTML
  • Port validation: Reject malformed port values instead of accepting leading digits across CLI flags, environment variables, aliases, and service configuration

0.15.9

New Features

  • Added Cloudflare quick tunnels with --cloudflare to share apps through a public URL
  • Added route uptime to the proxy app list and portless list
  • Added hostname filtering and a newest-first toggle to the proxy's 404-page app list

Bug Fixes

  • Fixed uppercase URL schemes so HTTP and HTTPS prefixes are removed correctly from hostnames

0.15.8

Bug Fixes

  • Terminal close cleanup: Closing the terminal now stops dev servers launched directly, through Turbo, or in multi-app mode instead of leaving them running on their ports
  • Crashed session cleanup: portless run and portless prune now stop orphaned command process groups from crashed sessions, including descendants outside the route port, while checking process identity before signaling
  • Windows command cleanup: Normal exits and force-kills now terminate the command tree, including Turbo and its dev servers, so orphaned processes no longer keep consuming shell input

0.15.7

New Features

  • Turbo JSONC workspaces: Workspaces that only have turbo.jsonc now use Turbo orchestration, the same as turbo.json
  • Unresolved hostname warning: After registering a route, portless asks the proxy to sync the hosts file and warns when the hostname still does not resolve
  • WebMCP documentation site: The docs moved to Geistdocs and expose their pages to agents through WebMCP

Bug Fixes

  • Hosts sync data loss: Manual hosts sync refuses to write when the hosts file cannot be read, verifies what it wrote, and removes stale managed entries when no routes remain
  • Local CA certificate: The generated CA no longer carries a duplicate Basic Constraints extension on OpenSSL 1.1.1 and LibreSSL, which macOS and OpenSSL 3 rejected and left *.localhost untrusted
  • Proxy crash on backend restart: The proxy no longer forwards client hop-by-hop headers, which left backend sockets open and crashed the proxy with ECONNRESET when a backend restarted
  • Wildcard fallback order: With --wildcard, an unregistered subdomain now routes to the most specific registered parent instead of the first one stored
  • Occupied app ports: Free app ports are checked on 127.0.0.1, ::1, 0.0.0.0 and ::, so portless no longer assigns a port another server already holds
  • Version manager node: Child commands now use the node a version manager put first on PATH; portless's own node directory is only a fallback

0.15.6

New Features

  • Agent-readable documentation: The documentation site now exposes clean Markdown pages and an llms.txt index so agents can discover and consume the docs directly

Bug Fixes

  • Framework flags through package scripts: Frameworks that ignore PORT now receive the assigned --port and --host flags when launched through supported package-manager scripts, while non-server and unsafe scripts are left untouched
  • Windows service lifetime: The Windows startup service now registers a Task Scheduler definition with no execution time limit and replaces existing tasks atomically
  • Ctrl+C process cleanup: Interrupt handling now waits for the command's process tree to exit and terminates remaining descendants after a short grace period

0.15.5

Bug Fixes

  • Multi-segment custom TLDs: --tld and PORTLESS_TLD now accept dotted DNS names such as dev.example.com, so local URLs can mirror production structure. Validation checks per-label DNS rules and the 253-character total limit, and overlapping TLDs resolve by longest match
  • WebSocket over HTTP/2: Proxy now advertises RFC 8441 extended CONNECT and bridges WebSocket streams to an HTTP/1.1 upgrade against the backend, fixing dev server HMR for browsers that negotiate h2 over ALPN. Plain-HTTP upgrades on the TLS port are proxied instead of dropped

0.15.4

Bug Fixes

  • Loopback-only proxy binding: Outside LAN mode, the proxy and HTTP redirect listeners now bind only to 127.0.0.1 and ::1, so Portless routes cannot be reached through LAN, VPN, or other network interfaces. LAN mode still binds to all interfaces explicitly

0.15.3

Bug Fixes

  • State directory under sudo: Portless now resolves per-user state from the original sudo user's home, so an elevated proxy and unprivileged app processes share the same routes instead of writing to separate state directories
  • Windows and WSL CA trust: On WSL, portless trust now installs the local CA in both Linux and Windows trust stores, while portless clean removes the exact certificate from both. Failed trust-store cleanup preserves the CA identity for safe retries, including on native Windows

0.15.2

Bug Fixes

  • Tailscale funnel routing: Proxy now routes requests addressed to a route's Tailscale funnel or serve hostname, so --funnel and --tailscale apps reached at <device>.ts.net no longer return a 404, including when several apps share one hostname on different ports
  • IPv6-only dev servers return 502: Proxy now dials upstreams over both loopback families, fixing 502s when a dev server binds ::1 only, such as Vite on Node 17+
  • Worktree prefix in multi-app mode: Bare portless in a monorepo worktree now applies the branch prefix in multi-app mode as it already did for single apps, so hostnames no longer collide across worktrees

0.15.1

New Features

  • Multi-TLD proxy support: --tld is now repeatable and PORTLESS_TLD accepts comma separated values, so one proxy can serve the same app names across multiple TLDs. Routes, TLS, service state, hosts sync, framework environment, and workspace launches now use the full configured TLD list

0.15.0

New Features

  • portless doctor: New read only diagnostics command checks Node.js, the state directory, proxy liveness, route entries, hostname resolution, HTTPS CA trust, and LAN prerequisites, then prints suggested fixes

Bug Fixes

  • HTTP/2 Host forwarding: Proxy now forwards HTTP/2 :authority as Host to HTTP/1.1 backends, fixing apps that depend on Host and previously saw 127.0.0.1 for browser traffic
  • --force takeover cleanup: Exit cleanup now removes only routes still owned by the exiting process, so a forced takeover does not deregister the new owner's route

0.14.0

New Features

  • ngrok sharing: New --ngrok flag exposes portless apps publicly through ngrok while keeping the local .localhost URL. Also configurable with PORTLESS_NGROK=1; child processes receive PORTLESS_NGROK_URL, and portless list shows active ngrok URLs

Improvements

  • ngrok tunnel lifecycle: Portless checks for the ngrok CLI before starting an app, surfaces install and authentication guidance, removes stopped ngrok URLs from route state, and terminates tunnel processes during app cleanup

0.13.1

New Features

  • Configurable startup services: portless service install now persists proxy options such as --port, --no-tls, --lan, --ip, --tld, --wildcard, --cert, --key, and --state-dir into launchd, systemd, and Task Scheduler. portless service status now reports the installed service port, HTTPS mode, TLD, LAN mode, wildcard mode, and state directory

Bug Fixes

  • Service reinstall port changes: Reinstalling the startup service with a different port now stops any existing proxy on the previous port before starting the new service
  • Service install validation: LAN service installs now fail early on platforms that cannot publish mDNS records, and service paths such as ~ are normalized before writing native service files

Requirements

  • Node.js 24: The published package now requires Node.js 24 or newer, and repository development uses pnpm 11 with a minimum release age policy

0.13.0

New Features

  • OS startup service: New portless service install, portless service status, and portless service uninstall commands manage a native startup service for the HTTPS proxy across macOS launchd, Linux systemd, and Windows Task Scheduler. The service starts clean .localhost URLs after reboot, and portless clean removes it during cleanup

Bug Fixes

  • Tailscale readiness preflight: --tailscale and --funnel now check Tailscale HTTPS and Funnel prerequisites before starting the child process, surfacing actionable errors instead of hanging during registration

0.12.0

New Features

  • Tailscale sharing: New --tailscale flag shares any portless app over your Tailscale network with zero framework config. Each app is root-mounted on its own Tailscale HTTPS port (443, 8443, 8444, ...) so no basePath configuration is needed. Works as a global flag, per-app flag (portless myapp --tailscale next dev), or env var (PORTLESS_TAILSCALE=1)
  • Tailscale Funnel: New --funnel flag exposes apps to the public internet through Tailscale Funnel. Implies --tailscale. Also configurable via PORTLESS_FUNNEL=1
  • PORTLESS_TAILSCALE_URL env var: Child processes receive PORTLESS_TAILSCALE_URL containing the Tailscale HTTPS URL so apps can reference their public address
  • Tailscale URLs in portless list: The list command now shows tailnet URLs alongside local URLs when Tailscale sharing is active

Improvements

  • portless prune cleans stale Tailscale registrations: Prune now removes orphaned tailscale serve entries left behind by dead CLI sessions
  • portless clean removes Tailscale serve state: Clean now tears down any Tailscale serve/funnel registrations alongside the usual CA and hosts file cleanup

0.11.1

New Features

  • portless prune command: Safety net to find and kill orphaned dev servers left behind by dead CLI sessions. Reads stale route entries, checks if something is still listening on each port, and terminates the orphan.

Bug Fixes

  • Zombie process orphaning on CLI crash: Spawn child processes with detached:true on Unix so they get their own process group. Signal handlers now kill the entire group instead of just the immediate child, preventing orphaned dev servers from surviving CLI crashes or kill -9.

0.11.0

New Features

  • Zero-arg mode: Run bare portless from any project directory to auto-discover the dev script from package.json and start it through the proxy. No arguments, no config required
  • Multi-app orchestration: In monorepos, bare portless auto-discovers workspace packages (pnpm, npm, yarn, bun) and starts all dev scripts concurrently through the proxy. Each package gets a subdomain derived from its npm scope (e.g. @acme/docs becomes docs.acme.localhost)
  • Turborepo integration: When turbo.json is present, portless delegates to turbo run <script> instead of spawning each app individually. Per-app PORT, HOST, and PORTLESS_URL are injected via a lightweight --require loader so turbo retains dependency ordering and task graph awareness. Set turbo: false in config to opt out
  • portless.json config file: Configure app name, script, port, and turbo settings without embedding portless in package.json scripts. Also supports a "portless" key in package.json as an inline alternative
  • --script flag: Override the default "dev" script for a single invocation (e.g. portless --script start)
  • Rsbuild support: Auto-inject --port and --host CLI flags for Rsbuild dev server

Bug Fixes

  • State directory moved to ~/.portless: All proxy state now lives in ~/.portless instead of /tmp/portless, fixing repeated CA trust prompts on macOS (where /tmp is periodically cleaned) and a symlink local privilege escalation vulnerability
  • Duplicate macOS CA certificates: Fix security delete-certificate failing with "is ambiguous" when multiple portless CA entries had accumulated in the keychain
  • CA trust marker caching: Cache the CA fingerprint after a successful trust so subsequent proxy starts skip the OS security check and avoid re-triggering the macOS authentication dialog

Improvements

  • Auto-trust CA on proxy auto-start: When the proxy is auto-started and the CA is not yet trusted, portless automatically runs trust with proper sudo elevation
  • Package manager delegation: Detects pnpm, yarn, bun, or npm and delegates script execution to the correct package manager
  • Non-server script detection: Build-only tools (tsup, tsc, esbuild, etc.) are auto-detected and run without a proxy route. Use proxy: false in config for explicit control
  • Monochrome CLI output: Bold for headers and errors, dim for warnings and muted text, no color codes

0.10.3

Bug Fixes

  • Stale TLD persisting across proxy restart: Fix the proxy reverting to a stale TLD (e.g. .local) after restart because transient state markers were not cleaned up on stop

0.10.2

New Features

  • Auto-inject NODE_EXTRA_CA_CERTS: Child processes spawned by portless run now automatically receive NODE_EXTRA_CA_CERTS pointing to the portless CA certificate, so Node.js subprocesses trust the local CA without manual configuration

Bug Fixes

  • Proxy startup on slow macOS security command: Fix the proxy failing to start when the macOS security command takes longer than expected to verify CA trust
  • Lock contention with parallel commands: Fix lock contention that could cause failures when multiple portless commands run simultaneously
  • ERR_HTTP2_PROTOCOL_ERROR during HMR: Fix HTTP/2 stream reset flood during hot module replacement causing protocol errors
  • Proxy auto-start in non-interactive terminals: Fix auto-start failing in non-interactive terminals (e.g. IDE task runners) and when previous proxy config exists

0.10.1

New Features

  • portless clean: New command stops the proxy if it is running, removes the local CA from the OS trust store when it was installed by portless, deletes allowlisted files under known state directories, and removes the portless-managed block from the hosts file. Custom --cert and --key paths are never removed

Improvements

  • Hosts file sync by default: The proxy now keeps the hosts file in sync with active routes automatically (improves Safari and other setups where .localhost subdomains do not resolve to loopback). Set PORTLESS_SYNC_HOSTS=0 to opt out. The managed block is removed from the hosts file when the proxy exits

0.10.0

New Features

  • LAN mode: New --lan flag exposes portless services to phones and other devices on the same network via mDNS .local hostnames. Auto-detects the active LAN IP, follows network changes, and supports --ip / PORTLESS_LAN_IP overrides for VPN or multi-interface setups. Publishes mDNS records with platform-native tools (dns-sd on macOS, avahi-publish-address on Linux). Adds *.local to generated certificate SANs so HTTPS works for LAN hostnames
  • VitePlus support: Auto-inject --port for VitePlus (vp) dev server

0.9.6

Bug Fixes

  • WebSocket proxy memory leak: Add socket close/end handlers to prevent memory leaks in the WebSocket proxy

0.9.5

Bug Fixes

  • --force kills existing process: --force now terminates the process that owns the conflicting route before registering a new one, instead of only removing the stale route entry
  • CA certificate included in TLS chain: The proxy now sends the CA certificate as part of the TLS chain, fixing UNABLE_TO_VERIFY_LEAF_SIGNATURE errors in clients that do not have the portless CA in their trust store

0.9.4

Bug Fixes

  • README missing from npm package: The published npm package now includes its README. Previously .gitignore excluded the copied README during packing; an .npmignore override fixes this

0.9.3

Breaking Changes

  • Origin/Referer header rewriting removed: The proxy no longer rewrites Origin and Referer headers. The feature introduced in 0.9.2 caused issues with certain backend frameworks and has been removed

0.9.2

New Features

  • Origin/Referer header rewriting: The proxy now rewrites Origin and Referer headers for portless-managed hostnames so backend CSRF protections accept proxied requests

Bug Fixes

  • Browser-blocked ports excluded from auto-selection: Ports that browsers refuse to connect to (e.g. 6666, 6667) are now excluded from automatic port assignment
  • State directory preserved during sudo elevation: Fix portless trust losing the state directory when elevating to sudo
  • Windows OpenSSL config detection: Auto-detect openssl.cnf location on Windows when OPENSSLDIR points to a non-existent path

0.9.1

New Features

  • Project dev dependency install: portless can now be installed as a project dev dependency (npm install -D portless) in addition to the global install. The npx/dlx guard now only blocks one-off downloads, not locally installed packages

Bug Fixes

  • portless trust on fresh install: Fix portless trust failing on a fresh install when no CA certificate exists yet. The command now generates the CA and server certificates automatically before trusting

0.9.0

Breaking Changes

  • HTTPS on port 443 is now the default: The proxy defaults to HTTPS on port 443 instead of HTTP on port 1355. Auto-elevates with sudo on macOS/Linux to bind privileged ports. Use --no-tls for plain HTTP on port 80, or -p 1355 for the previous unprivileged port
  • PORTLESS_HTTPS env var inverted: HTTPS is on by default; set PORTLESS_HTTPS=0 to disable (replaces the old PORTLESS_HTTPS=1 opt-in)

New Features

  • HTTP-to-HTTPS redirect: When the HTTPS proxy runs on port 443, a companion HTTP server on port 80 automatically redirects all requests to HTTPS
  • Auto-sudo for proxy lifecycle: portless proxy start auto-elevates with sudo when binding privileged ports. portless proxy stop does the same when the running proxy is owned by root
  • Clean URLs: URLs are now https://myapp.localhost instead of http://myapp.localhost:1355. No port numbers to remember

0.8.0

Breaking Changes

  • Strict subdomain routing is now the default: Subdomains no longer automatically match parent hostnames (e.g. api.myapp.localhost no longer routes to myapp.localhost). Use the --wildcard flag or PORTLESS_WILDCARD=1 env var to restore the previous behavior

New Features

  • --wildcard flag: Opt in to wildcard subdomain routing where subdomains match registered parent hostnames. Configurable via PORTLESS_WILDCARD env var

Bug Fixes

  • Cert generation with dots in $HOME: Fix TLS certificate generation failing when the home directory path contains dots
  • DNS label limit for --name flag: Fix regression where long --name values could exceed the 63-character DNS label limit
  • Windows DEP0190 deprecation warning: Silence Node.js deprecation warning on Windows by replacing shell: true with explicit cmd.exe /d /s /c spawning
  • Windows duplicate PATH entries: Deduplicate PATH environment variables in child process spawn on Windows

Improvements

  • Removed chalk dependency: Replaced chalk with lightweight ANSI color utilities to reduce install size
  • Automated release process: Added CI workflow for automated npm publishing and GitHub releases

0.7.2

Bug Fixes

  • --port injection for package runners: Fix --port injection for commands run via package runners like npx, pnpm dlx, etc. (#150)
  • TLS cert generation: Fix TLS cert generation for long hostnames and proxy startup races (#149)
  • Proxy crash on ECONNRESET: Handle ECONNRESET errors on TLS wrapper sockets to prevent proxy crash (#127)
  • Windows node not recognized: Resolve node not recognized error on Windows when running portless run (#126)

Documentation

  • Added Windows to docs requirements section (#122)

Improvements

  • Added GitHub Action to automatically publish packages to npm on release (#130)

0.7.1

Documentation

  • Updated docs site header (#118)

0.7.0

Features

  • Windows support: Full cross-platform support for Windows. Uses os.tmpdir() for state directory, netstat for port detection, certutil for CA trust, and platform-aware command spawning. Includes Windows CI

Bug Fixes

  • --name sanitization in portless run: Stop replacing dots with hyphens in --name values. Dots are valid and intentional in hostnames like local.metaview
  • Worktree prefix only for linked worktrees: Only prepend the branch name for linked worktrees, not the root worktree. Previously any non-main branch got a prefix when multiple worktrees existed
  • Windows hosts file paths: Use platform-aware hosts file path and error messages (Administrator vs sudo)

0.6.0

Features

  • Custom TLD: Use --tld to set a custom TLD (e.g. .test) instead of .localhost. Configurable via PORTLESS_TLD env var. Auto-syncs /etc/hosts for custom TLDs when started with sudo. Warns about risky TLDs like .local and .dev. Recommended: .test (IANA-reserved, no collision risk)
  • portless get command: Print the URL for a service, useful for wiring services together (e.g. BACKEND_URL=$(portless get backend)). Applies worktree prefix by default; use --no-worktree to skip
  • --name flag for portless run: Override the inferred base name while preserving the worktree prefix (e.g. portless run --name myapp next dev in a worktree produces fix-ui.myapp.localhost)

Bug Fixes

  • HTTPS proxy trust and stop on macOS with sudo: Fix CA trust check and proxy stop when the proxy was started with sudo on macOS
  • DNS label length for worktree hostnames: Truncate worktree-prefixed hostnames to respect the 63-character DNS label limit

0.5.2

Documentation

  • Add git worktree documentation. portless run automatically detects linked worktrees and prefixes the URL with the branch name (e.g. fix-ui.myapp.localhost)

0.5.1

Bug Fixes

  • npm README: Copy root README.md into the package at publish time so it appears on npmjs.com
  • homepage: Point npm homepage to https://port1355.dev

0.5.0

Features

  • portless run subcommand: Automatically infer the project name from package.json, git root, or directory name instead of specifying it manually. In git worktrees, the branch name is prepended as a subdomain prefix (e.g. fix-ui.myapp.localhost) so each worktree gets a unique URL with no config changes
  • portless alias command: Register routes for services not spawned by portless (e.g. Docker containers with published ports)
  • PORTLESS_URL env var: Child processes receive PORTLESS_URL containing the public .localhost URL so apps can self-reference their own URL
  • --app-port flag: Specify a fixed port for the app instead of automatic assignment, also configurable via PORTLESS_APP_PORT env var
  • Wildcard subdomain routing: Subdomains now match registered hostnames (e.g. tenant.myapp.localhost routes to myapp.localhost). Exact matches take priority
  • /etc/hosts sync: Automatically sync .localhost hostnames to /etc/hosts for environments where .localhost does not resolve to 127.0.0.1
  • Multi-distro Linux CA trust: portless trust now supports Arch, Fedora/RHEL/CentOS, and openSUSE in addition to Debian/Ubuntu
  • Expo and React Native support: Auto-inject --port and --host flags for expo start and react-native start
  • Branded error and status pages: The proxy renders styled HTML pages for 404, 502, 508, and other status codes

Bug Fixes

  • Stream errors: Handle proxy stream errors gracefully to prevent unhandled exceptions from crashing the proxy

0.4.2

Bug Fixes

  • spawn ENOENT: Use /bin/sh -c for command execution so shell scripts and version-manager shims (nvm, fnm, mise) are resolved correctly. Prepend node_modules/.bin to PATH so local project binaries (e.g. next, vite) are found without a global install.
  • sudo state directory permissions: System state directory (/tmp/portless) now uses world-writable + sticky-bit permissions so non-root processes can register routes after a sudo proxy start. State files created under sudo are chowned back to the real user.
  • duplicate route names: addRoute now checks for an existing live route and throws an error if the hostname is already registered by a running process. Use --force to override.
  • TLS SHA-1 rejection: Force SHA-256 for all CA and server certificate generation. Detect and regenerate existing SHA-1 certificates automatically.
  • per-hostname certs for .localhost subdomains: Issue a per-hostname certificate with an exact SAN for every .localhost subdomain. *.localhost wildcard certs are invalid because .localhost is a reserved TLD per RFC 2606.
  • terminal left in raw mode: Reset stdin.setRawMode(false) on process exit so the terminal is not left in raw mode after Ctrl+C.

Features

  • proxy loop detection: Detect forwarding loops (e.g. a Vite dev server proxying back through portless without changeOrigin: true) using the X-Portless-Hops header. Respond with 508 Loop Detected and a message explaining the fix. Also detects loops on WebSocket upgrades.
  • --force flag: Override a route registered by another process with portless <name> --force <cmd>.

0.4.1

  • Fixed Vite support by auto-injecting --port and --host flags
  • Added e2e tests for 11 frameworks (Next.js, Vite, Nuxt, Astro, Angular, SvelteKit, Remix, Solid, React Router, Hono, Express)
  • Improved docs site
  • Added Turborepo for build orchestration

0.4.0

  • HTTP/2 + TLS support with auto-generated certificates
  • sudo portless trust command for system CA trust
  • --https, --cert, --key, --no-tls flags for proxy start
  • HTTPS configuration via portless.json and PORTLESS_HTTPS env var

0.3.0

  • Configuration file support (portless.json)
  • PORTLESS=0 to bypass the proxy
  • Port range customization

0.2.2

  • Bug fixes and stability improvements

0.2.1

  • Fixed proxy routing issues

0.2.0

  • Added --port flag to the proxy command

0.1.0

  • Initial release
  • Reverse proxy on port 1355
  • Named .localhost URLs for dev servers
  • portless list command
  • Subdomain support

Apps Docs Content Docs Commands (`apps/docs/content/docs/commands.mdx`)


title: "Commands"

Zero-arg mode

bash
portless

Runs the "dev" script from package.json through the proxy. The app name is inferred from the package name, git root, or directory name. From a monorepo root (detected via pnpm-workspace.yaml or package.json "workspaces"), starts all workspace packages that have the target script.

bash
portless                      # single app: run dev script
portless                      # monorepo root: start all apps
portless --script start       # run "start" instead of "dev"

Use a portless.json to override defaults. See Configuration.

Run an app

bash
portless run [--name <name>] [cmd] [args...]
portless <name> <cmd> [args...]

portless run infers the project name from portless.json, package.json, git root, or directory name. When no command is given, runs the configured script (default: "dev") from package.json. Use --name to override the inferred name while still applying worktree prefixes.

portless <name> uses an explicit name with no inference or prefixing.

bash
portless run                             # run dev script through proxy
portless run next dev                    # infer name from project
portless run --name myapp next dev       # override inferred name
portless myapp next dev                  # explicit name
portless api pnpm start
portless docs.myapp next dev
Flag Description
--name Override the inferred base name (worktree prefix still applies). Only for portless run.
--script Run a specific package.json script instead of the default ("dev"). Global flag.
--app-port Use a fixed port for the app instead of auto-assignment. Also configurable via PORTLESS_APP_PORT or portless.json.
--force Override an existing route registered by another process
--tailscale Share the app on your Tailscale network.
--funnel Share the app publicly via Tailscale Funnel.
--ngrok Share the app publicly via ngrok.
--cloudflare Share the app publicly via a Cloudflare quick tunnel. Requires cloudflared.
bash
portless myapp --tailscale next dev
portless myapp --funnel next dev
portless myapp --ngrok next dev

Get a service URL

bash
portless get <name>

Print the URL for a service. Useful for wiring services together in scripts or env vars:

bash
BACKEND_URL=$(portless get backend)

Applies worktree prefix detection by default. Use --no-worktree to skip it.

Alias (static routes)

bash
portless alias <name> <port>
portless alias <name> <port> --force
portless alias --remove <name>

Register a route for a service not managed by portless (e.g. a Docker container). Aliases persist across stale-route cleanup.

bash
portless alias my-postgres 5432     # -> https://my-postgres.localhost
portless alias redis 6379           # -> https://redis.localhost
portless alias --remove my-postgres # remove the alias

List routes

bash
portless list

Shows active routes, their assigned ports, and how long each app has been running.

Doctor

bash
portless doctor

Checks local portless health without changing state. It reports Node.js compatibility, state directory permissions, proxy liveness, route entries, HTTPS CA trust, hostname resolution, and LAN mode prerequisites, then prints suggested fixes.

Trust the CA

bash
portless trust

Adds the portless certificate authority to your system trust store. Required once for HTTPS with auto-generated certs.

Clean up

bash
portless clean

Stops the proxy if it is running, removes the portless CA from the OS trust store when portless installed it, deletes allowlisted files under ~/.portless, the system state directory, and PORTLESS_STATE_DIR when set, and removes the portless block from /etc/hosts. May prompt for elevated privileges. Custom --cert / --key paths are not removed.

Prune orphans

bash
portless prune
portless prune --force

Finds and kills orphaned dev server processes left behind by crashed portless sessions. When the CLI is killed with kill -9 or crashes, child dev servers may survive and continue holding their ports. This command checks routes whose owning process is dead but whose port is still in use, terminates the orphans, and removes the stale route entries. Use --force to send SIGKILL instead of SIGTERM.

On macOS and Linux, prune stops the whole process group portless started for the route, including processes that do not hold the port. A group is only signaled when its leader still has the start time portless recorded at spawn, so a reused PID is never targeted. portless run does the same cleanup on startup for routes whose session died.

Proxy control

Start

bash
portless proxy start
Flag Description
-p, --port Proxy port (default: 443, or 80 with --no-tls). Auto-elevates with sudo.
--no-tls Disable HTTPS (use plain HTTP on port 80)
--https Enable HTTPS (default, accepted for compatibility)
--lan Enable LAN mode (mDNS .local domains for real device testing)
--ip Override auto-detected LAN IP (use with --lan)
--tld Use a custom TLD instead of .localhost (e.g. test, dev.example.com). Repeat for more TLDs. Hostnames still sync to /etc/hosts by default.
--cert Custom TLS certificate
--key Custom TLS private key
--foreground Run in foreground instead of daemon mode

Stop

bash
portless proxy stop

OS startup service

bash
portless service install
portless service install --lan
portless service install --wildcard
PORTLESS_STATE_DIR=~/.portless-lan PORTLESS_LAN=1 portless service install
portless service status
portless service uninstall

Installs the proxy as an OS startup service. The default is HTTPS on port 443 with .localhost names, but service install accepts proxy options including --port, --no-tls, --lan, --ip, --tld, --wildcard, --cert, and --key. Use --state-dir or PORTLESS_STATE_DIR= to choose where service state and logs are written.

The chosen service configuration is written into launchd, systemd, or Task Scheduler and reused after reboot. portless service status reports the installed port, HTTPS mode, TLDs, LAN mode, wildcard mode, and state directory. macOS and Linux use a root-owned service so port 443 can bind at boot. Windows uses a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. portless clean automatically removes the service.

LAN mode

Access services from phones and other devices on the same WiFi via mDNS (.local domains):

bash
portless proxy start --lan
portless proxy start --lan --https
portless proxy start --lan --ip 192.168.1.42   # manual IP override

Without LAN mode, the proxy and HTTP redirect listener bind only to 127.0.0.1 and ::1. LAN mode explicitly binds them to 0.0.0.0 and ::.

Make it permanent by adding export PORTLESS_LAN=1 to your shell profile. Portless also remembers LAN mode via proxy.lan, so a stopped LAN proxy starts in LAN mode again unless you override it with PORTLESS_LAN=0 for that start.

Uses dns-sd on macOS (built-in) and avahi-publish-address on Linux (avahi-utils). Not supported on Windows.

Framework notes:

  • Next.js: Add allowedDevOrigins: ['myapp.local', '*.myapp.local'] to next.config.js so LAN mode requests still work when portless adds a git worktree prefix.
  • Vite / React Router / SvelteKit / Astro: Handled automatically via __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS.
  • Expo / React Native: Add NSAllowsLocalNetworking to your app.json for iOS ATS (see Getting Started for the .localhost equivalent using NSExceptionDomains):
json
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSAppTransportSecurity": {
          "NSAllowsLocalNetworking": true
        }
      }
    }
  }
}

Hosts

bash
portless hosts sync     # reconcile current routes with /etc/hosts
portless hosts clean    # remove portless entries from /etc/hosts

Auto-sync is on by default. Set PORTLESS_SYNC_HOSTS=0 to disable. If a route hostname will not resolve, the command that registered it warns and points you to portless hosts sync.

Manual sync reconciles portless-managed entries with current routes and removes stale entries when there are no routes. It requires a successful initial hosts-file read before writing and verifies each write. A read or verification failure follows the normal sync error path.

Bypass portless

bash
PORTLESS=0 pnpm dev

Runs the command directly without the proxy.

Info

bash
portless --help
portless --version

Apps Docs Content Docs Configuration (`apps/docs/content/docs/configuration.mdx`)


title: "Configuration"

Bare portless works out of the box. It runs the "dev" script from package.json through the proxy, inferring the app name from the package name, git root, or directory:

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

portless.json

Use an optional portless.json to override defaults:

json
{ "name": "myapp" }
bash
portless        # runs "dev" script, https://myapp.localhost

The name is inferred from package.json if not set in config. The script defaults to "dev".

Fields

Field Type Default Description
name string inferred Base app name. Worktree prefix still applies.
script string "dev" Name of a package.json script to run.
appPort number auto Fixed port for the child process.
proxy boolean auto Whether to route through the proxy. Auto-detected from the command; set false for
text
non-server scripts. |

| apps | object | | Overrides for workspace packages, keyed by relative path. |
| turbo | boolean | true | Set false to use direct spawning instead of turborepo in multi-app mode. |

Each apps entry has the same shape (name, script, appPort, proxy). When apps is present, top-level fields apply only in single-app mode.

package.json "portless" key

Instead of a separate portless.json, you can add a "portless" key to your package.json. A string value is shorthand for setting the name:

json
{
  "name": "@myorg/web",
  "portless": "myapp"
}

An object supports all per-app fields (name, script, appPort, proxy):

json
{
  "name": "@myorg/web",
  "portless": { "name": "myapp", "script": "dev:app" }
}

The package.json "portless" key takes precedence over portless.json app entries but is overridden by CLI flags.

Monorepo

One portless.json at the repo root covers all workspace packages. Portless discovers packages from pnpm-workspace.yaml, or the "workspaces" field in package.json (npm, yarn, bun):

json
{
  "apps": {
    "apps/web": { "name": "myapp" },
    "apps/api": { "name": "api.myapp" }
  }
}
bash
portless                  # from repo root: start all packages with a "dev" script
cd apps/web && portless   # start just one package
portless --script start   # run "start" instead of "dev"

The apps map is optional and only needed for overrides. Packages not listed still auto-discover with names inferred from their package.json.

Without an apps map, hostnames follow the ..localhost convention. The project name comes from the most common npm scope across workspace packages (e.g. @myorg/web and @myorg/api produce project name myorg), falling back to the workspace root directory name. If a package's short name matches the project name, it gets the bare .localhost without duplication.

Turborepo

For turborepo projects, use portless as the dev script and the real command in a separate script:

json
{
  "scripts": {
    "dev": "portless",
    "dev:app": "next dev"
  },
  "portless": { "name": "myapp", "script": "dev:app" }
}

pnpm dev at the root runs turbo, which runs portless in each package. Portless detects the package manager and runs pnpm run dev:app through the proxy. No turbo.json or turbo.jsonc changes are needed.

When portless runs from a workspace root, it uses the existing Turbo integration to preserve task ordering when either turbo.json or turbo.jsonc is readable. Set "turbo": false in the root portless configuration to use direct spawning instead.

People without portless installed can run pnpm run dev:app directly.

Precedence

Closest config wins, like Prettier and ESLint:

For name: CLI --name flag > package.json "portless" key > portless.json app entry > package.json inference.

For script: CLI --script flag > package.json "portless" key > portless.json app entry > default "dev".

For appPort: CLI --app-port flag > PORTLESS_APP_PORT env var > package.json "portless" key > portless.json app entry > auto-assigned.

Environment variables

Variable Description Default
PORTLESS_PORT Proxy port 443 (HTTPS) / 80 (HTTP)
PORTLESS_HTTPS HTTPS on by default; set to 0 to disable (same as --no-tls) on
PORTLESS_LAN Set to 1 to always enable LAN mode (mDNS .local domains) off
PORTLESS_TLD Use one or more TLDs, single or multi-segment (e.g. localhost,dev.example.com) localhost
PORTLESS_APP_PORT Use a fixed port for the app (skip auto-assignment) random 4000 to 4999
PORTLESS_SYNC_HOSTS Set to 0 to disable auto-sync of /etc/hosts on
PORTLESS_STATE_DIR Override the state directory ~/.portless
PORTLESS_TAILSCALE Set to 1 to share apps on your Tailscale network off
PORTLESS_FUNNEL Set to 1 to share apps publicly via Tailscale Funnel off
PORTLESS_NGROK Set to 1 to share apps publicly via ngrok off
PORTLESS_CLOUDFLARE Set to 1 to share apps publicly via a Cloudflare quick tunnel off
PORTLESS Set to 0 to bypass the proxy enabled

State directory

Portless stores state (routes, PID file, port file, TLS marker) in ~/.portless on all platforms. Override with PORTLESS_STATE_DIR.

State files

File Purpose
routes.json Maps hostnames to ports
routes.lock Prevents concurrent writes
proxy.pid PID of the running proxy
proxy.port Port the proxy is listening on
proxy.log Proxy daemon log output
proxy.lan Remembers LAN mode and stores the last known LAN IP

Port assignment

Apps get a random port in the 4000--4999 range. Portless sets PORT and usually HOST before running your command. Most frameworks respect PORT automatically. For frameworks that ignore it (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, or a bare vite); a command that does not serve, such as vite build, vite optimize, vp test or astro check, rejects them and is left alone. 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.


Apps Docs Content Docs Https (`apps/docs/content/docs/https.mdx`)


title: "HTTPS"

HTTP/2 + TLS is enabled by default for faster dev server page loads. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers serving many unbundled files. HTTP/2 multiplexes all requests over a single connection.

First run generates a local CA and server certs, then adds the CA to your system trust store. After that, no prompts, no browser warnings.

On WSL, portless updates both the Linux trust store and the Windows current-user Root store. Windows browsers can then verify portless HTTPS certificates served from WSL.

Custom certificates

Use your own certs (e.g., from mkcert):

bash
portless proxy start --cert ./cert.pem --key ./key.pem

Trust the CA later

If you skipped the trust prompt on first run:

bash
portless trust

Disable HTTPS

Use --no-tls to run with plain HTTP on port 80:

bash
portless proxy start --no-tls

Apps Docs Content Docs Meta.Json (`apps/docs/content/docs/meta.json`)

{
"pages": ["index", "why", "commands", "https", "configuration", "changelog"]
}


Apps Docs Content Docs Why (`apps/docs/content/docs/why.mdx`)


title: "Why Portless"

Local dev with port numbers is fragile. Portless fixes that by giving each dev server a stable, named .localhost URL.

Port conflicts

Two projects default to the same port and you get EADDRINUSE. With portless, each app gets an auto-assigned port behind a named URL. No collisions.

Memorizing ports

Was the API on 3001 or 8080? With portless, it is always https://api.localhost.

Wrong app on refresh

Stop one server, start another on the same port, and your open tab shows something completely different. Named URLs eliminate this.

Monorepo multiplier

Every problem above scales with each service in the repo. Portless handles any number of services with distinct hostnames.

Agents test the wrong port

AI coding agents guess or hardcode the wrong port, especially in monorepos. A stable URL like https://myapp.localhost is deterministic and reliable.

Cookies set on localhost bleed across apps on different ports. localStorage is lost when ports shift. Each .localhost subdomain gets its own scope.

Hardcoded ports in config

CORS allowlists, OAuth redirect URIs, and .env files all break when ports change. With portless, URLs are stable across restarts.

Sharing URLs with teammates

"What port is that on?" becomes a Slack question. With portless, everyone uses the same named URL.

Browser history

Your history for localhost:3000 is a jumble of unrelated projects. Named URLs keep things organized.


--- METRICS ---