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.
- "dev": "next dev" # http://localhost:3000
+ "dev": "portless run next dev" # https://myapp.localhostInstall
Global (recommended):
npm install portlessOr as a project dev dependency:
npm install -D portlessportless 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
portless myapp next dev
# -> https://myapp.localhostHTTPS 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:
portless # -> runs "dev" script, https://<project>.localhostUse an optional portless.json to override defaults:
{ "name": "myapp" }portless # -> runs "dev" script, https://myapp.localhostThe 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):
{
"apps": {
"apps/web": { "name": "myapp" },
"apps/api": { "name": "api.myapp" }
}
}portless # from repo root: starts all workspace packages with a "dev" script
cd apps/web && portless # start just one packageThe 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:
{
"name": "@myorg/web",
"portless": "myapp"
}An object supports all per-app fields (name, script, appPort, proxy):
{
"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:
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:
{
"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:
{
"scripts": {
"dev": "portless run next dev"
}
}With a portless.json, you can simplify to:
{
"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:
portless api.myapp pnpm start
# -> https://api.myapp.localhost
portless docs.myapp next dev
# -> https://docs.myapp.localhostBy 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:
# 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.localhostUse --name to override the inferred base name while keeping the worktree prefix:
portless run --name myapp next dev # -> https://fix-ui.myapp.localhostPut 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:
portless proxy start --tld test
portless myapp next dev
# -> https://myapp.testThe 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:
portless proxy start --tld localhost --tld test
portless myapp next dev
# -> https://myapp.localhost
# -> https://myapp.testWhen 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:
portless proxy start --tld dev.example.com
portless myapp next dev
# -> https://myapp.dev.example.comEach 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
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- Start the proxy: auto-starts when you run an app, or start explicitly with
portless proxy start - Run apps:
portless <name> <command>assigns a free port and registers with the proxy - Access via URL:
https://<name>.localhostroutes 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.
# 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 trustOn 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:
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 uninstallThe 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
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
.localhostnames toallowedDevOrigins: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 localhostoutside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing--hostorHOST.
Tailscale sharing
Share your dev server with teammates on your Tailscale network:
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.
portless myapp --tailscale next dev # -> https://devbox.ts.net
portless api --tailscale pnpm start # -> https://devbox.ts.net:8443Use --funnel to expose your dev server to the public internet via Tailscale Funnel:
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:
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:
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
/* Detailed source-code truncated for AI context efficiency. */Options
/* Detailed source-code truncated for AI context efficiency. */Environment variables
/* Detailed source-code truncated for AI context efficiency. */Reserved names:
run,get,alias,hosts,list,doctor,trust,clean,prune,proxy, andserviceare subcommands and cannot be used as app names directly. Useportless run <cmd>to infer the name from your project, orportless --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):
portless cleanmacOS/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:
portless hosts sync # Reconcile current routes with /etc/hosts
portless hosts clean # Clean up laterAuto-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):
server: {
proxy: {
"/api": {
target: "https://api.myapp.localhost",
changeOrigin: true,
ws: true,
},
},
}webpack-dev-server (webpack.config.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.
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 PrettierRequirements
- Node.js 24+
- macOS, Linux, or Windows
- Tailscale CLI (optional, for
--tailscaleand--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:
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.
Recommended: use a domain you own
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:
portless proxy start --tld dev
portless oauth-test.local.ctate next dev
# -> https://oauth-test.local.ctate.devSince 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
npm install portless2. Start the proxy
portless proxy start --tld devPortless defaults to HTTPS on port 443 (auto-elevates with sudo), so URLs don't need a port number.
3. Create a Google OAuth client
- Go to Google Cloud Console > Credentials
- Click "Create Credentials" > "OAuth client ID"
- Choose "Web application"
- Add your domain to "Authorized JavaScript origins" (e.g.
https://oauth-test.dev) - Add the callback URL to "Authorized redirect URIs" (e.g.
https://oauth-test.dev/api/auth/callback/google) - Copy the Client ID and Client Secret
4. Configure the example
cd examples/google-oauth
cp .env.example .envEdit .env with your credentials:
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.devGenerate a secret:
openssl rand -base64 325. Install and run
pnpm install
pnpm devThis 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:
NEXTAUTH_URL=http://localhost:3000The 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:
pnpm --filter @portless/docs test
pnpm --filter @portless/docs test:routesThe 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:
VERCEL_ENV=production pnpm --filter @portless/docs build
VERCEL_ENV=production DOCS_EXPECT_NOINDEX=0 pnpm --filter @portless/docs test:routesTo verify preview indexing, build and run with matching environment values:
VERCEL_ENV=preview pnpm --filter @portless/docs build
VERCEL_ENV=preview DOCS_EXPECT_NOINDEX=1 pnpm --filter @portless/docs test:routesA 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.
- "dev": "next dev" # http://localhost:3000
+ "dev": "portless myapp next dev" # https://myapp.localhostInstall
Global (recommended):
npm install portlessOr as a project dev dependency:
npm install -D portlessportless 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>.localhostThe 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.localhostHTTPS 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:
{
"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.localhostGit 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.localhostPut 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.localhostCustom 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.testThe 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:
portless proxy start --tld localhost --tld test
portless myapp next dev
# -> https://myapp.localhost
# -> https://myapp.testWhen 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":
portless proxy start --tld dev.example.com
portless myapp next dev
# -> https://myapp.dev.example.comThis 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.
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:
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 --checkBoth 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
--cloudflareto 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 runandportless prunenow 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.jsoncnow use Turbo orchestration, the same asturbo.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
*.localhostuntrusted - 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
ECONNRESETwhen 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.0and::, 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.txtindex so agents can discover and consume the docs directly
Bug Fixes
- Framework flags through package scripts: Frameworks that ignore
PORTnow receive the assigned--portand--hostflags 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:
--tldandPORTLESS_TLDnow accept dotted DNS names such asdev.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.1and::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 trustnow installs the local CA in both Linux and Windows trust stores, whileportless cleanremoves 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
--funneland--tailscaleapps reached at<device>.ts.netno 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
::1only, such as Vite on Node 17+ - Worktree prefix in multi-app mode: Bare
portlessin 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:
--tldis now repeatable andPORTLESS_TLDaccepts 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
:authorityasHostto HTTP/1.1 backends, fixing apps that depend on Host and previously saw127.0.0.1for browser traffic --forcetakeover 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
--ngrokflag exposes portless apps publicly through ngrok while keeping the local.localhostURL. Also configurable withPORTLESS_NGROK=1; child processes receivePORTLESS_NGROK_URL, andportless listshows 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 installnow persists proxy options such as--port,--no-tls,--lan,--ip,--tld,--wildcard,--cert,--key, and--state-dirinto launchd, systemd, and Task Scheduler.portless service statusnow 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, andportless service uninstallcommands manage a native startup service for the HTTPS proxy across macOS launchd, Linux systemd, and Windows Task Scheduler. The service starts clean.localhostURLs after reboot, andportless cleanremoves it during cleanup
Bug Fixes
- Tailscale readiness preflight:
--tailscaleand--funnelnow 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
--tailscaleflag 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 nobasePathconfiguration is needed. Works as a global flag, per-app flag (portless myapp --tailscale next dev), or env var (PORTLESS_TAILSCALE=1) - Tailscale Funnel: New
--funnelflag exposes apps to the public internet through Tailscale Funnel. Implies--tailscale. Also configurable viaPORTLESS_FUNNEL=1 PORTLESS_TAILSCALE_URLenv var: Child processes receivePORTLESS_TAILSCALE_URLcontaining 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 prunecleans stale Tailscale registrations: Prune now removes orphanedtailscale serveentries left behind by dead CLI sessionsportless cleanremoves 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 prunecommand: 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:trueon 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 orkill -9.
0.11.0
New Features
- Zero-arg mode: Run bare
portlessfrom any project directory to auto-discover the dev script frompackage.jsonand start it through the proxy. No arguments, no config required - Multi-app orchestration: In monorepos, bare
portlessauto-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/docsbecomesdocs.acme.localhost) - Turborepo integration: When
turbo.jsonis present, portless delegates toturbo run <script>instead of spawning each app individually. Per-appPORT,HOST, andPORTLESS_URLare injected via a lightweight--requireloader so turbo retains dependency ordering and task graph awareness. Setturbo: falsein config to opt out portless.jsonconfig file: Configure app name, script, port, and turbo settings without embedding portless inpackage.jsonscripts. Also supports a"portless"key inpackage.jsonas an inline alternative--scriptflag: Override the default"dev"script for a single invocation (e.g.portless --script start)- Rsbuild support: Auto-inject
--portand--hostCLI flags for Rsbuild dev server
Bug Fixes
- State directory moved to
~/.portless: All proxy state now lives in~/.portlessinstead of/tmp/portless, fixing repeated CA trust prompts on macOS (where/tmpis periodically cleaned) and a symlink local privilege escalation vulnerability - Duplicate macOS CA certificates: Fix
security delete-certificatefailing 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: falsein 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 byportless runnow automatically receiveNODE_EXTRA_CA_CERTSpointing to the portless CA certificate, so Node.js subprocesses trust the local CA without manual configuration
Bug Fixes
- Proxy startup on slow macOS
securitycommand: Fix the proxy failing to start when the macOSsecuritycommand takes longer than expected to verify CA trust - Lock contention with parallel commands: Fix lock contention that could cause failures when multiple
portlesscommands run simultaneously ERR_HTTP2_PROTOCOL_ERRORduring 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--certand--keypaths 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
.localhostsubdomains do not resolve to loopback). SetPORTLESS_SYNC_HOSTS=0to opt out. The managed block is removed from the hosts file when the proxy exits
0.10.0
New Features
- LAN mode: New
--lanflag exposes portless services to phones and other devices on the same network via mDNS.localhostnames. Auto-detects the active LAN IP, follows network changes, and supports--ip/PORTLESS_LAN_IPoverrides for VPN or multi-interface setups. Publishes mDNS records with platform-native tools (dns-sdon macOS,avahi-publish-addresson Linux). Adds*.localto generated certificate SANs so HTTPS works for LAN hostnames - VitePlus support: Auto-inject
--portfor 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
--forcekills existing process:--forcenow 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_SIGNATUREerrors 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
.gitignoreexcluded the copied README during packing; an.npmignoreoverride fixes this
0.9.3
Breaking Changes
- Origin/Referer header rewriting removed: The proxy no longer rewrites
OriginandRefererheaders. 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
OriginandRefererheaders 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 trustlosing the state directory when elevating to sudo - Windows OpenSSL config detection: Auto-detect
openssl.cnflocation on Windows whenOPENSSLDIRpoints 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. Thenpx/dlxguard now only blocks one-off downloads, not locally installed packages
Bug Fixes
portless truston fresh install: Fixportless trustfailing 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-tlsfor plain HTTP on port 80, or-p 1355for the previous unprivileged port PORTLESS_HTTPSenv var inverted: HTTPS is on by default; setPORTLESS_HTTPS=0to disable (replaces the oldPORTLESS_HTTPS=1opt-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 startauto-elevates with sudo when binding privileged ports.portless proxy stopdoes the same when the running proxy is owned by root - Clean URLs: URLs are now
https://myapp.localhostinstead ofhttp://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.localhostno longer routes tomyapp.localhost). Use the--wildcardflag orPORTLESS_WILDCARD=1env var to restore the previous behavior
New Features
--wildcardflag: Opt in to wildcard subdomain routing where subdomains match registered parent hostnames. Configurable viaPORTLESS_WILDCARDenv 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
--nameflag: Fix regression where long--namevalues could exceed the 63-character DNS label limit - Windows
DEP0190deprecation warning: Silence Node.js deprecation warning on Windows by replacingshell: truewith explicitcmd.exe /d /s /cspawning - Windows duplicate
PATHentries: DeduplicatePATHenvironment 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
--portinjection for package runners: Fix--portinjection for commands run via package runners likenpx,pnpm dlx, etc. (#150)- TLS cert generation: Fix TLS cert generation for long hostnames and proxy startup races (#149)
- Proxy crash on ECONNRESET: Handle
ECONNRESETerrors on TLS wrapper sockets to prevent proxy crash (#127) - Windows
node not recognized: Resolvenode not recognizederror on Windows when runningportless 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,netstatfor port detection,certutilfor CA trust, and platform-aware command spawning. Includes Windows CI
Bug Fixes
--namesanitization inportless run: Stop replacing dots with hyphens in--namevalues. Dots are valid and intentional in hostnames likelocal.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
--tldto set a custom TLD (e.g..test) instead of.localhost. Configurable viaPORTLESS_TLDenv var. Auto-syncs/etc/hostsfor custom TLDs when started with sudo. Warns about risky TLDs like.localand.dev. Recommended:.test(IANA-reserved, no collision risk) portless getcommand: 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-worktreeto skip--nameflag forportless run: Override the inferred base name while preserving the worktree prefix (e.g.portless run --name myapp next devin a worktree producesfix-ui.myapp.localhost)
Bug Fixes
- HTTPS proxy trust and stop on macOS with sudo: Fix CA trust check and
proxy stopwhen 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 runautomatically 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.mdinto the package at publish time so it appears on npmjs.com - homepage: Point npm homepage to https://port1355.dev
0.5.0
Features
portless runsubcommand: Automatically infer the project name frompackage.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 changesportless aliascommand: Register routes for services not spawned by portless (e.g. Docker containers with published ports)PORTLESS_URLenv var: Child processes receivePORTLESS_URLcontaining the public.localhostURL so apps can self-reference their own URL--app-portflag: Specify a fixed port for the app instead of automatic assignment, also configurable viaPORTLESS_APP_PORTenv var- Wildcard subdomain routing: Subdomains now match registered hostnames (e.g.
tenant.myapp.localhostroutes tomyapp.localhost). Exact matches take priority /etc/hostssync: Automatically sync.localhosthostnames to/etc/hostsfor environments where.localhostdoes not resolve to127.0.0.1- Multi-distro Linux CA trust:
portless trustnow supports Arch, Fedora/RHEL/CentOS, and openSUSE in addition to Debian/Ubuntu - Expo and React Native support: Auto-inject
--portand--hostflags forexpo startandreact-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 -cfor command execution so shell scripts and version-manager shims (nvm, fnm, mise) are resolved correctly. Prependnode_modules/.bintoPATHso 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:
addRoutenow checks for an existing live route and throws an error if the hostname is already registered by a running process. Use--forceto 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
.localhostsubdomains: Issue a per-hostname certificate with an exact SAN for every.localhostsubdomain.*.localhostwildcard certs are invalid because.localhostis 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 theX-Portless-Hopsheader. Respond with508 Loop Detectedand a message explaining the fix. Also detects loops on WebSocket upgrades. --forceflag: Override a route registered by another process withportless <name> --force <cmd>.
0.4.1
- Fixed Vite support by auto-injecting
--portand--hostflags - 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 trustcommand for system CA trust--https,--cert,--key,--no-tlsflags for proxy start- HTTPS configuration via
portless.jsonandPORTLESS_HTTPSenv var
0.3.0
- Configuration file support (
portless.json) PORTLESS=0to 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
--portflag to the proxy command
0.1.0
- Initial release
- Reverse proxy on port 1355
- Named
.localhostURLs for dev servers portless listcommand- Subdomain support
Apps Docs Content Docs Commands (`apps/docs/content/docs/commands.mdx`)
title: "Commands"
Zero-arg mode
portlessRuns 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.
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
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.
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. |
portless myapp --tailscale next dev
portless myapp --funnel next dev
portless myapp --ngrok next devGet a service URL
portless get <name>Print the URL for a service. Useful for wiring services together in scripts or env vars:
BACKEND_URL=$(portless get backend)Applies worktree prefix detection by default. Use --no-worktree to skip it.
Alias (static routes)
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.
portless alias my-postgres 5432 # -> https://my-postgres.localhost
portless alias redis 6379 # -> https://redis.localhost
portless alias --remove my-postgres # remove the aliasList routes
portless listShows active routes, their assigned ports, and how long each app has been running.
Doctor
portless doctorChecks 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
portless trustAdds the portless certificate authority to your system trust store. Required once for HTTPS with auto-generated certs.
Clean up
portless cleanStops 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
portless prune
portless prune --forceFinds 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
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
portless proxy stopOS startup service
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 uninstallInstalls 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):
portless proxy start --lan
portless proxy start --lan --https
portless proxy start --lan --ip 192.168.1.42 # manual IP overrideWithout 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']tonext.config.jsso 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
NSAllowsLocalNetworkingto yourapp.jsonfor iOS ATS (see Getting Started for the.localhostequivalent usingNSExceptionDomains):
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true
}
}
}
}
}Hosts
portless hosts sync # reconcile current routes with /etc/hosts
portless hosts clean # remove portless entries from /etc/hostsAuto-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
PORTLESS=0 pnpm devRuns the command directly without the proxy.
Info
portless --help
portless --versionApps 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:
portless # runs "dev" script, https://<project>.localhostportless.json
Use an optional portless.json to override defaults:
{ "name": "myapp" }portless # runs "dev" script, https://myapp.localhostThe 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 |
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:
{
"name": "@myorg/web",
"portless": "myapp"
}An object supports all per-app fields (name, script, appPort, proxy):
{
"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):
{
"apps": {
"apps/web": { "name": "myapp" },
"apps/api": { "name": "api.myapp" }
}
}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:
{
"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):
portless proxy start --cert ./cert.pem --key ./key.pemTrust the CA later
If you skipped the trust prompt on first run:
portless trustDisable HTTPS
Use --no-tls to run with plain HTTP on port 80:
portless proxy start --no-tlsApps 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.
Cookie and storage clashes
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 ---
- Files Extracted: 14
- Estimated Token Budget: ~23519 tokens
- Canonical Reference: https://codewiki.google/github.com/vercel-labs/portless