# Technical Documentation: vercel-labs/portless

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/vercel-labs/portless)

### Overview (`README.md`)

# portless

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

<p>
  <a href="https://vercel.com/labs#labs-products"><img alt="Vercel Labs Product" src="https://img.shields.io/badge/LABS-PRODUCT-0a0a0a.svg?style=for-the-badge&amp;logo=Vercel&amp;labelColor=000000" height="28"></a>
  <a href="https://www.npmjs.com/package/portless"><img alt="npm version: portless" src="https://img.shields.io/npm/v/portless.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
  <a href="https://github.com/vercel-labs/portless/blob/main/LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/github/license/vercel-labs/portless.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
  <a href="https://www.npmjs.com/package/portless"><img alt="npm downloads per month: portless" src="https://img.shields.io/npm/dm/portless.svg?style=for-the-badge&amp;labelColor=000000&amp;label=npm%20downloads" height="28"></a>
</p>

```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 `<package>.<project>.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 `<project>.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 <path>` or `PORTLESS_STATE_DIR=<path>` 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](https://tailscale.com) 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](https://tailscale.com/kb/1223/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](https://ngrok.com):

```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`](https://developers.cloudflare.com/tunnel/downloads/) 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`, 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](https://turbo.build). 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/next@16.3.4.patch` 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](tests/README.md) for production/preview commands, frozen content baselines and server cleanup. [Verification](tests/VERIFICATION.md) 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](https://publicsuffix.org/). `.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.

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

```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](https://console.cloud.google.com/apis/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:

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

```
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.

```
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](/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
```

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>`--name <name>`</td>
      <td>Override the inferred base name (worktree prefix still applies). Only for `portless run`.</td>
    </tr>
    <tr>
      <td>`--script <name>`</td>
      <td>Run a specific `package.json` script instead of the default (`"dev"`). Global flag.</td>
    </tr>
    <tr>
      <td>`--app-port <number>`</td>
      <td>Use a fixed port for the app instead of auto-assignment. Also configurable via `PORTLESS_APP_PORT` or `portless.json`.</td>
    </tr>
    <tr>
      <td>`--force`</td>
      <td>Override an existing route registered by another process</td>
    </tr>
    <tr>
      <td>`--tailscale`</td>
      <td>Share the app on your Tailscale network.</td>
    </tr>
    <tr>
      <td>`--funnel`</td>
      <td>Share the app publicly via Tailscale Funnel.</td>
    </tr>
    <tr>
      <td>`--ngrok`</td>
      <td>Share the app publicly via ngrok.</td>
    </tr>
    <tr>
      <td>`--cloudflare`</td>
      <td>Share the app publicly via a Cloudflare quick tunnel. Requires `cloudflared`.</td>
    </tr>
  </tbody>
</table>

```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
```

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>`-p, --port <number>`</td>
      <td>Proxy port (default: 443, or 80 with `--no-tls`). Auto-elevates with sudo.</td>
    </tr>
    <tr>
      <td>`--no-tls`</td>
      <td>Disable HTTPS (use plain HTTP on port 80)</td>
    </tr>
    <tr>
      <td>`--https`</td>
      <td>Enable HTTPS (default, accepted for compatibility)</td>
    </tr>
    <tr>
      <td>`--lan`</td>
      <td>Enable LAN mode (mDNS `.local` domains for real device testing)</td>
    </tr>
    <tr>
      <td>`--ip <address>`</td>
      <td>Override auto-detected LAN IP (use with `--lan`)</td>
    </tr>
    <tr>
      <td>`--tld <tld>`</td>
      <td>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.</td>
    </tr>
    <tr>
      <td>`--cert <path>`</td>
      <td>Custom TLS certificate</td>
    </tr>
    <tr>
      <td>`--key <path>`</td>
      <td>Custom TLS private key</td>
    </tr>
    <tr>
      <td>`--foreground`</td>
      <td>Run in foreground instead of daemon mode</td>
    </tr>
  </tbody>
</table>

### 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 <path>` or `PORTLESS_STATE_DIR=<path>` 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

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Type</th>
      <th>Default</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>`name`</td>
      <td>string</td>
      <td>inferred</td>
      <td>Base app name. Worktree prefix still applies.</td>
    </tr>
    <tr>
      <td>`script`</td>
      <td>string</td>
      <td>`"dev"`</td>
      <td>Name of a `package.json` script to run.</td>
    </tr>
    <tr>
      <td>`appPort`</td>
      <td>number</td>
      <td>auto</td>
      <td>Fixed port for the child process.</td>
    </tr>
    <tr>
      <td>`proxy`</td>
      <td>boolean</td>
      <td>auto</td>
      <td>
        Whether to route through the proxy. Auto-detected from the command; set `false` for
        non-server scripts.
      </td>
    </tr>
    <tr>
      <td>`apps`</td>
      <td>object</td>
      <td></td>
      <td>Overrides for workspace packages, keyed by relative path.</td>
    </tr>
    <tr>
      <td>`turbo`</td>
      <td>boolean</td>
      <td>`true`</td>
      <td>Set `false` to use direct spawning instead of turborepo in multi-app mode.</td>
    </tr>
  </tbody>
</table>

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 `<package>.<project>.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 `<project>.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

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

## State directory

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

## State files

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>`routes.json`</td>
      <td>Maps hostnames to ports</td>
    </tr>
    <tr>
      <td>`routes.lock`</td>
      <td>Prevents concurrent writes</td>
    </tr>
    <tr>
      <td>`proxy.pid`</td>
      <td>PID of the running proxy</td>
    </tr>
    <tr>
      <td>`proxy.port`</td>
      <td>Port the proxy is listening on</td>
    </tr>
    <tr>
      <td>`proxy.log`</td>
      <td>Proxy daemon log output</td>
    </tr>
    <tr>
      <td>`proxy.lan`</td>
      <td>Remembers LAN mode and stores the last known LAN IP</td>
    </tr>
  </tbody>
</table>

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

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