# Agent Rules
## Package Manager
Use `pnpm` for all package management commands (not npm or yarn).
Exception: End-user install instructions should use `npm install -g` (global) or `npm install -D` (project dev dependency) since npm is universal.
## Dependencies
Always check for the latest npm version when adding dependencies. Use `pnpm add <package>` (without version) to get the latest, or verify with `npm view <package> version` first.
## No Emojis
Do not use emojis anywhere in this repository (code, comments, output, docs).
## Dashes
Never use `--` as a dash in prose, comments, or user-facing output. Use an em dash (\u2014) when a dash is needed, but prefer rephrasing to avoid dashes entirely. The only exception is CLI flags (e.g. `--port`).
## Boolean Environment Variables
Document boolean env vars using only `0` and `1` in CLI help, SKILL.md, docs pages, and README. Code accepts `true`/`false` as well (and `skip` for `PORTLESS`), but these alternatives are not documented.
## Docs Updates
When a change affects how humans or agents use portless (new/changed/removed commands, flags, behavior, or config), update all of these:
1. `README.md` (user-facing documentation)
2. `skills/portless/SKILL.md` (agent skill for using portless)
3. `packages/portless/src/cli.ts` (`--help` output)
## Releasing
Releases are manual, single-PR affairs. The maintainer controls the changelog voice and format.
To prepare a release:
1. Create a branch (e.g. `prepare-v1.2.0`)
2. Bump the version in `packages/portless/package.json`
3. Write the changelog entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
4. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry (only the latest release should have markers)
5. Add a matching entry to `apps/docs/src/app/changelog/page.mdx`
6. Open a PR and merge to `main`
CI compares the version in `packages/portless/package.json` to what's on npm. If it differs, it builds, publishes, and creates the GitHub release automatically. The release body is extracted from the content between the markers.
## Windows Debugging
A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr.
All scripts require `AWS_PROFILE=portless-debug` (or the profile must be set as default). Prefix every command with it or export it for the session:
```bash
export AWS_PROFILE=portless-debug
```
### Prerequisites
The instance must be provisioned first (one-time, by a human):
```bash
./scripts/windows-debug/provision.sh
```
Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC.
### Usage
Start the instance (if stopped):
```bash
./scripts/windows-debug/start.sh
```
Run a command on Windows:
```bash
./scripts/windows-debug/run.sh "<powershell-command>"
```
Sync the current git branch and rebuild:
```bash
./scripts/windows-debug/sync.sh
```
Stop the instance when done (avoids cost):
```bash
./scripts/windows-debug/stop.sh
```
### Important notes
**SSM agent takes a long time to come online.** After starting or restarting the instance, the SSM agent can take 5 to 10 minutes before it accepts commands. If `run.sh` returns `InvalidInstanceId`, wait and retry. Do not assume the instance is broken; poll with increasing intervals.
**PowerShell uses `;` not `&&`.** The `run.sh` wrapper executes PowerShell, which does not support `&&` as a command separator. Use `;` instead:
```bash
./scripts/windows-debug/run.sh "cd C:\portless; pnpm test"
```
**OpenSSL may not be at the expected path.** The bootstrap installs OpenSSL to `C:\Program Files\OpenSSL-Win64\bin`, but this can fail silently. Git bundles its own OpenSSL at `C:\Program Files\Git\mingw64\bin`. If `openssl` is not found, add Git's path:
```bash
./scripts/windows-debug/run.sh '$env:PATH = "C:\Program Files\Git\mingw64\bin;$env:PATH"; openssl version'
```
**SSM runs as SYSTEM.** Commands execute as the SYSTEM account, not a normal user. This affects user-specific operations (e.g., `certutil -addstore -user Root` targets SYSTEM's trust store, not a real user's). Keep this in mind when testing user-facing features.
### Common Workflows
Run unit tests on Windows:
```bash
./scripts/windows-debug/run.sh "cd C:\portless; pnpm test"
```
Run e2e tests on Windows:
```bash
./scripts/windows-debug/run.sh "cd C:\portless; pnpm test:e2e"
```
Check bootstrap progress (first boot only):
```bash
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
```
The repo lives at `C:\portless` on the instance. Node.js 24, pnpm 11, Git, and OpenSSL are pre-installed. The `run.sh` wrapper automatically adds these tools to PATH.
<!-- opensrc:start -->
## Source Code Reference
Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details.
See `opensrc/sources.json` for the list of available packages and their versions.
Use this source code when you need to understand how a package works internally, not just its types/interface.
### Fetching Additional Source Code
To fetch source code for a package or repository you need to understand, run:
```bash
npx opensrc <package> # npm package (e.g., npx opensrc zod)
npx opensrc pypi:<package> # Python package (e.g., npx opensrc pypi:requests)
npx opensrc crates:<package> # Rust crate (e.g., npx opensrc crates:serde)
npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)
```
<!-- opensrc:end -->
---
name: portless
description: Set up and use portless for named local dev server URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when integrating portless into a project, configuring dev server names, setting up the local proxy, working with .localhost domains, or troubleshooting port/proxy issues.
---
# Portless
Replace port numbers with stable, named .localhost URLs. For humans and agents.
## Why portless
- **Port conflicts**: `EADDRINUSE` when two projects default to the same port
- **Memorizing ports**: which app is on 3001 vs 8080?
- **Refreshing shows the wrong app**: stop one server, start another on the same port, stale tab shows wrong content
- **Monorepo multiplier**: every problem scales with each service in the repo
- **Agents test the wrong port**: AI agents guess or hardcode the wrong port
- **Cookie/storage clashes**: cookies on `localhost` bleed across apps; localStorage lost when ports shift
- **Hardcoded ports in config**: CORS allowlists, OAuth redirects, `.env` files break when ports change
- **Sharing URLs with teammates**: "what port is that on?" becomes a Slack question
- **Browser history is useless**: `localhost:3000` history is a mix of unrelated projects
## Installation
Install globally (recommended) or as a project dev dependency. Do NOT use `npx` or `pnpm dlx` for one-off execution.
```bash
# Global (available everywhere)
npm install -g portless
# Or per-project dev dependency
npm install -D portless
```
When installed per-project, invoke via package.json scripts or `npx portless` (since the package is local, npx will not download anything).
## Quick Start
```bash
# Install globally (or add -D to a project)
npm install -g portless
# Run your app (auto-starts the HTTPS proxy on port 443)
portless run next dev
# -> https://<project>.localhost
# Or with an explicit name
portless myapp next dev
# -> https://myapp.localhost
```
The proxy auto-starts when you run an app. You can also start it explicitly with `portless proxy start`. Auto-start 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 always take priority.
In non-interactive environments (no TTY, or `CI=1`), portless exits with a descriptive error instead of prompting. Task runners like turborepo should pre-start the proxy.
## Integration Patterns
### Zero-config (recommended)
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
pnpm dev # -> works without portless, plain "next dev"
```
Use an optional `portless.json` to override defaults (name, script, port):
```json
{ "name": "myapp" }
```
```bash
portless # -> runs "dev" script, https://myapp.localhost
```
### Monorepo
One `portless.json` at the repo root. 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 provides name overrides. Unlisted packages auto-discover with inferred names.
Without an `apps` map, hostnames follow `<package>.<project>.localhost`. The project name comes from the most common npm scope (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 uses the bare `<project>.localhost`.
### Turborepo
For turborepo projects, use portless as the `dev` script with the real command in a separate script:
```json
{
"scripts": { "dev": "portless", "dev:app": "next dev" },
"portless": { "name": "myapp", "script": "dev:app" }
}
```
`pnpm dev` runs turbo, which runs `portless` in each package. Portless detects the package manager and runs `pnpm run dev:app` through the proxy.
### package.json scripts
You can still use portless directly in scripts:
```json
{
"scripts": {
"dev": "portless run next dev"
}
}
```
The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`.
### Multi-app setups with subdomains
```bash
portless myapp next dev # https://myapp.localhost
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). Start the proxy with `--wildcard` to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app). Exact matches always take priority over wildcards.
### Git worktrees
`portless run` automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain prefix so each worktree gets a unique URL:
```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
```
No config changes needed. Put `portless run` in `package.json` once and it works in all worktrees.
### Bypassing portless
Set `PORTLESS=0` to run the command directly without the proxy:
```bash
PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
```
## How It Works
1. `portless proxy start` starts an HTTPS reverse proxy on port 443 as a background daemon. Auto-elevates with sudo on macOS/Linux; falls back to port 1355 if sudo is unavailable. Use `--no-tls` for plain HTTP on port 80. Configurable with `-p` / `--port` or the `PORTLESS_PORT` env var. The proxy also auto-starts when you run an app.
2. `portless <name> <cmd>` assigns a random free port (4000-4999) via the `PORT` env var and registers the app with the proxy
3. The browser hits `https://<name>.localhost`; the proxy forwards to the app's assigned port
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.
`.localhost` domains resolve to `127.0.0.1` natively in Chrome, Firefox, and Edge. Safari relies on the system DNS resolver, which may not handle `.localhost` subdomains on all configurations. Run `portless hosts sync` to add entries to `/etc/hosts` if needed.
Use `portless proxy start --tld localhost --tld test` to serve the same app names under multiple TLDs from one proxy. `PORTLESS_URL` uses the first configured TLD. When configured TLDs overlap (e.g. `example.com` and `dev.example.com`), hostnames are matched against the longest TLD first, regardless of configuration order. `PORTLESS_TLD` accepts the same comma separated list format, e.g. `PORTLESS_TLD=localhost,test`.
TLDs can be multi-segment DNS names such as `dev.example.com`, so local URLs can mirror production structure (`myapp.dev.example.com`). Each label follows DNS rules: lowercase letters, digits, interior hyphens, 63 characters per label, 253 total. Strict OAuth providers that reject `.localhost` redirect URIs accept a real domain like `https://myapp.dev.example.com/api/auth/callback/google`.
Most frameworks (Next.js, Express, Nuxt, etc.) respect the `PORT` env var automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the correct `--port` flag and, when needed, a matching `--host` CLI 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.
### State directory
Portless stores its state (routes, PID file, port file) in `~/.portless`. When the proxy runs under sudo, this remains the invoking user's home directory so unprivileged apps and the proxy share route registrations. Override with the `PORTLESS_STATE_DIR` environment variable.
### Environment variables
| Variable | Description |
| --------------------- | ------------------------------------------------------------------------------ |
| `PORTLESS_PORT` | Override the default proxy port (default: 443 with HTTPS, 80 without) |
| `PORTLESS_APP_PORT` | Use a fixed port for the app (skip auto-assignment) |
| `PORTLESS_HTTPS` | HTTPS on by default; set to `0` to disable (same as `--no-tls`) |
| `PORTLESS_LAN` | Set to `1` to always enable LAN mode (auto-detects LAN IP) |
| `PORTLESS_LAN_IP` | Pin a specific LAN IP for LAN mode |
| `PORTLESS_TLD` | Use one or more TLDs, single or multi-segment (e.g. localhost,dev.example.com) |
| `PORTLESS_WILDCARD` | Set to `1` to allow unregistered subdomains to fall back to parent |
| `PORTLESS_SYNC_HOSTS` | Set to `0` to disable auto-sync of /etc/hosts (on by default) |
| `PORTLESS_TAILSCALE` | Set to `1` to share apps on your Tailscale network (same as `--tailscale`) |
| `PORTLESS_FUNNEL` | Set to `1` to share apps publicly via Tailscale Funnel (same as `--funnel`) |
| `PORTLESS_NGROK` | Set to `1` to share apps publicly via ngrok (same as `--ngrok`) |
| `PORTLESS_STATE_DIR` | Override the state directory |
| `PORTLESS=0` | Bypass the proxy, run the command directly |
### HTTP/2 + HTTPS
HTTPS with HTTP/2 is enabled by default (faster page loads for dev servers with many files). WebSockets work over both HTTP/1.1 (Upgrade) and HTTP/2 (RFC 8441 extended CONNECT), so dev server HMR works through the proxy. First run generates a local CA and adds it to the system trust store. After that, no prompts and no browser warnings.
```bash
portless proxy start --cert ./c.pem --key ./k.pem # Use custom certs
portless proxy start --no-tls # Disable HTTPS (plain HTTP)
portless trust # Add CA to trust store later
```
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.
### 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 advertises `<name>.local` hostnames over mDNS so devices on the same Wi-Fi can reach your apps. Portless auto-detects your LAN IP and follows network changes automatically, but you can pin a specific address with `--ip <address>` or the `PORTLESS_LAN_IP` environment variable. Set `PORTLESS_LAN=1` to default to LAN mode every time the proxy starts.
Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start 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 helpers that portless launches: macOS includes `dns-sd`, while Linux uses `avahi-publish-address` from `avahi-utils` (install via `sudo apt install avahi-utils` or your distroβs tooling).
- **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 dev servers with teammates on your Tailscale network using `--tailscale`, or expose to the public internet with `--funnel`:
```bash
portless myapp --tailscale next dev
# -> https://myapp.localhost (local)
# -> https://devbox.yourteam.ts.net (tailnet)
portless myapp --funnel next dev
# -> https://myapp.localhost (local)
# -> https://devbox.yourteam.ts.net (public internet)
```
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.
Each `--tailscale` app is root-mounted on its own Tailscale HTTPS port (443, then 8443, 8444, etc.) so no framework `basePath` configuration is needed. Set `PORTLESS_TAILSCALE=1` to share every app by default. `portless list` shows both local and tailnet URLs. Tailscale serve registrations are cleaned up when the app exits. Requires `tailscale` CLI installed and connected, with Tailscale HTTPS certificates enabled.
### ngrok sharing
Expose a dev server to the public internet with ngrok using `--ngrok`:
```bash
portless myapp --ngrok next dev
# -> https://myapp.localhost (local)
# -> https://abc123.ngrok.app (public internet)
```
Set `PORTLESS_NGROK=1` to enable ngrok by default when portless runs an app. `portless list` shows both local and ngrok URLs. The ngrok tunnel is cleaned up when the app exits. Requires the `ngrok` CLI to be installed and authenticated with `ngrok config add-authtoken <token>`.
## OS startup service
Use the service command when users want the proxy to start automatically after reboot:
```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 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 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.
## CLI Reference
| Command | Description |
| ------------------------------------------------- | -------------------------------------------------------------- |
| `portless` | Run dev script through proxy |
| `portless` | From monorepo root: run all workspace packages |
| `portless --script <name>` | Run a specific package.json script (default: dev) |
| `portless run [cmd] [args...]` | Infer name from project, run through proxy (auto-starts) |
| `portless run --name <name> <cmd>` | Override inferred base name (worktree prefix still applies) |
| `portless <name> <cmd> [args...]` | Run app at `https://<name>.localhost` (auto-starts proxy) |
| `portless get <name>` | Print URL for a service (for cross-service wiring) |
| `portless get <name> --no-worktree` | Print URL without worktree prefix |
| `portless list` | Show active routes |
| `portless doctor` | Check proxy, routes, DNS, CA trust, and LAN prerequisites |
| `portless trust` | Add local CA to system trust store (for HTTPS) |
| `portless clean` | Remove state, CA trust entry, and /etc/hosts block |
| `portless prune` | Kill orphaned dev servers from crashed sessions |
| `portless prune --force` | Kill orphans with SIGKILL instead of SIGTERM |
| `portless proxy start` | Start HTTPS proxy as a daemon (port 443, auto-elevates) |
| `portless proxy start --no-tls` | Start without HTTPS (plain HTTP on port 80) |
| `portless proxy start --lan` | Start in LAN mode (mDNS `.local`, auto-follows LAN IP changes) |
| `portless proxy start -p <number>` | Start the proxy on a custom port |
| `portless proxy start --tld test` | Use .test instead of .localhost |
| `portless proxy start --tld localhost --tld test` | Serve both TLDs from one proxy |
| `portless proxy start --tld dev.example.com` | Use a multi-segment TLD for production-parity URLs |
| `portless proxy start --foreground` | Start the proxy in foreground (for debugging) |
| `portless proxy start --wildcard` | Allow unregistered subdomains to fall back to parent route |
| `portless proxy stop` | Stop the proxy |
| `portless service install` | Start the HTTPS proxy when the OS starts |
| `portless service install --lan` | Start the service in LAN mode |
| `portless service install --wildcard` | Persist wildcard routing in the startup service |
| `portless service status` | Show service and proxy status |
| `portless service uninstall` | Remove the startup service |
| `portless alias <name> <port>` | Register a static route (e.g. for Docker containers) |
| `portless alias <name> <port> --force` | Overwrite an existing route |
| `portless alias --remove <name>` | Remove a static route |
| `portless hosts sync` | Add routes to /etc/hosts (fixes Safari) |
| `portless hosts clean` | Remove portless entries from /etc/hosts |
| `portless <name> --app-port <n> <cmd>` | Use a fixed port for the app instead of auto-assignment |
| `portless <name> --tailscale <cmd>` | Share the app on your Tailscale network (tailnet) |
| `portless <name> --funnel <cmd>` | Share the app publicly via Tailscale Funnel |
| `portless <name> --ngrok <cmd>` | Share the app publicly via ngrok |
| `portless <name> --force <cmd>` | Kill the existing process and take over its route |
| `portless --name <name> <cmd>` | Force `<name>` as app name (bypasses subcommand dispatch) |
| `portless <name> -- <cmd> [args...]` | Stop flag parsing; everything after `--` is passed to child |
| `portless --help` / `-h` | Show help |
| `portless run --help` | Show help for a subcommand (also: alias, hosts, clean) |
| `portless --version` / `-v` | Show version |
**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, or `portless --name <name> <cmd>` to force any name including reserved ones.
## portless.json
Optional config file. Portless looks for it in the current directory.
| Field | Type | Default | Description |
| --------- | ------- | -------------------------- | -------------------------------------------------------- |
| `name` | string | inferred from package.json | Base app name (worktree prefix still applies) |
| `script` | string | `"dev"` | Name of a package.json script to run |
| `appPort` | number | auto-assigned | Fixed port for the child process |
| `proxy` | boolean | auto-detected | Whether to route through the proxy (`false` for tasks) |
| `apps` | object | | Overrides for workspace packages, keyed by relative path |
| `turbo` | boolean | `true` | Set `false` to use direct spawning instead of turborepo |
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
{ "portless": "myapp" }
```
An object supports all per-app fields (`name`, `script`, `appPort`, `proxy`):
```json
{ "portless": { "name": "myapp", "script": "dev:app" } }
```
Precedence (closest wins): CLI flags > package.json `"portless"` key > portless.json app entry > defaults.
## Troubleshooting
### Run diagnostics
Use `portless doctor` first when local routing or HTTPS behavior looks wrong. It is read-only and checks Node.js, state directory permissions, proxy liveness, route entries, hostname resolution, local CA trust, and LAN mode prerequisites.
### Proxy not running
The proxy auto-starts when you run an app with `portless <name> <cmd>`. If it doesn't start (e.g. port conflict), start it manually:
```bash
portless proxy start
```
### Port already in use
Another process is bound to the proxy port. Either stop it first, or use a different port:
```bash
portless proxy start -p 8080
```
### Framework not respecting PORT
Portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag for frameworks that ignore the `PORT` env var: **Vite**, **VitePlus** (`vp`), **Astro**, **React Router**, **Angular**, **Expo**, and **React Native**. SvelteKit uses Vite internally and is handled automatically. Injection reaches through a package script whose command starts with the framework or a known runner, and only for the framework's server commands (`dev`, `serve`, `preview`, `start`, or a bare `vite`) β `vite build`, `vite optimize`, `vp test` and other non-serving commands reject the flags, so they are left untouched, as is any invocation portless cannot classify (`vp --mode dev build`). It is also skipped for a compound command (`&&`, `|`, `;`), a trailing `#` comment, its own `--` option terminator, an env prefix (`NODE_ENV=production vite`), delegation to another script, and runner flags before the script name (`bun run --bun dev`) β each of those keeps its own port and the app returns 502, so set the port in the script yourself.
For other frameworks that don't read `PORT`, pass the port manually:
- **Webpack Dev Server**: use `--port $PORT`
- **Custom servers**: read `process.env.PORT` and listen on it
### Permission errors
The default ports (80 for HTTP, 443 for HTTPS) require `sudo` on macOS and Linux. Portless auto-elevates with sudo when needed. If sudo is unavailable, it falls back to port 1355 (no sudo needed). On Windows, no elevation is required.
```bash
portless proxy start --https # Auto-elevates with sudo for port 443
portless proxy start -p 1355 --https # No sudo needed (URLs include :1355)
portless proxy stop # Stop (use sudo if started with sudo)
```
### Safari can't find .localhost URLs
Safari relies on the system DNS resolver for `.localhost` subdomains, which may not resolve them on all macOS configurations. Chrome, Firefox, and Edge have built-in handling.
Fix:
```bash
portless hosts sync # Adds current routes to /etc/hosts
portless hosts clean # Remove entries later
```
Auto-syncs `/etc/hosts` for route hostnames by default. Set `PORTLESS_SYNC_HOSTS=0` to disable.
### Browser shows certificate warning with --https
The local CA may not be trusted yet. Run:
```bash
portless trust
```
This adds the portless local CA to your system trust store. After that, restart the browser.
### Remove portless from the machine
```bash
portless clean
```
Stops the proxy if needed, removes the portless CA from the trust store (when portless added it), deletes known files under state directories, and removes the portless `/etc/hosts` block. May require `sudo` on macOS/Linux. If trust-store removal fails, portless retains its CA certificate and key so a later `portless clean` can safely retry.
### Proxy loop (508 Loop Detected)
If your dev server proxies requests to another portless app (e.g. Vite proxying `/api` to `api.myapp.localhost`), the proxy must rewrite the `Host` header. Without this, portless routes the request back to the original app, creating an infinite loop.
Fix: set `changeOrigin: true` in the proxy config (Vite, webpack-dev-server, etc.):
```ts
// vite.config.ts
proxy: {
"/api": {
target: "https://api.myapp.localhost",
changeOrigin: true,
ws: 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.
### Tailscale not working
If `--tailscale` or `--funnel` fails:
```bash
tailscale status # Check if connected
tailscale up # Connect to your tailnet
```
Requires the Tailscale CLI to be installed (https://tailscale.com/download) and on PATH.
### ngrok not working
If `--ngrok` fails:
```bash
ngrok version # Check if installed
ngrok config add-authtoken <token> # Configure authentication
```
Requires the ngrok CLI to be installed (https://ngrok.com/download) and on PATH.
### Requirements
- Node.js 24+
- macOS, Linux, or Windows
- `openssl` (for `--https` cert generation; ships with macOS and most Linux distributions; on Windows, install via `winget install -e --id ShiningLight.OpenSSL.Dev` or use the copy bundled with Git for Windows)
- `tailscale` CLI (optional, for `--tailscale` and `--funnel`)
- `ngrok` CLI (optional, for `--ngrok`)
---
name: oauth
description: Configure OAuth providers (Google, Apple, Microsoft, Facebook, GitHub, etc.) to work with portless local dev URLs. Use when setting up OAuth redirect URIs, fixing "redirect_uri_mismatch" or "invalid redirect" errors, configuring sign-in providers for local development, or when a provider rejects .localhost subdomains. Triggers include "OAuth not working with portless", "redirect URI mismatch", "Google/Apple/Microsoft sign-in fails locally", "configure OAuth for local dev", or any task involving OAuth callback URLs with portless domains.
---
# OAuth with Portless
OAuth providers validate redirect URIs against domain rules. `.localhost` subdomains fail on most providers because they are not in the Public Suffix List or are explicitly blocked. Portless fixes this with `--tld` to serve apps on real, valid domains.
## The Problem
When portless uses the default `.localhost` TLD, OAuth providers reject redirect URIs like `http://myapp.localhost:1355/callback`:
| Provider | `localhost` | `.localhost` subdomains | Reason |
| --------- | ----------- | ----------------------- | ------------------------------ |
| Google | Allowed | Rejected | Not in their bundled PSL |
| Apple | Rejected | Rejected | No localhost at all |
| Microsoft | Allowed | Allowed | Permissive localhost handling |
| Facebook | Allowed | Varies | Must register each URI exactly |
| GitHub | Allowed | Allowed | Permissive |
Google and Apple are the strictest. Microsoft and GitHub are more lenient with localhost.
## The Fix
Use a valid TLD so the redirect URI passes provider validation:
```bash
portless proxy start --tld dev
portless myapp next dev
# -> https://myapp.dev
```
Any TLD in the Public Suffix List works: `.dev`, `.app`, `.com`, `.io`, etc.
### Use a domain you own
Bare TLDs like `.dev` mean `myapp.dev` could collide with a real domain. Use a multi-segment TLD under a domain you control, so the app name stays clean and the domain structure lives in the TLD:
```bash
portless proxy start --tld local.yourcompany.dev
portless myapp next dev
# -> https://myapp.local.yourcompany.dev
```
This ensures no outbound traffic reaches something you don't own. For teams, set a wildcard DNS record (`*.local.yourcompany.dev -> 127.0.0.1`) so every developer gets resolution without `/etc/hosts`, and every developer shares the same redirect URIs in the provider console.
## Provider Setup
### Google
1. Go to [Google Cloud Console > Credentials](https://console.cloud.google.com/apis/credentials)
2. Create or edit an OAuth 2.0 Client ID (Web application)
3. Add the portless domain to **Authorized JavaScript origins**: `https://myapp.dev`
4. Add the callback to **Authorized redirect URIs**: `https://myapp.dev/api/auth/callback/google`
Google validates domains against the Public Suffix List. The domain must end with a recognized TLD. `.localhost` subdomains fail this check; `.dev`, `.app`, `.com`, etc. all pass.
HTTPS is required for `.dev` and `.app` (HSTS-preloaded). Portless handles this automatically with `--https`.
### Apple
Apple Sign In does not allow `localhost` or IP addresses at all.
1. Go to [Apple Developer > Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources)
2. Register a Services ID
3. Configure Sign In with Apple, adding the portless domain as a **Return URL**: `https://myapp.dev/api/auth/callback/apple`
The domain must be a real, publicly-resolvable domain name. Since portless maps the domain to 127.0.0.1 locally, the browser resolves it but Apple's server-side validation may require the domain to resolve publicly too. If Apple rejects the domain, add a public DNS A record pointing to 127.0.0.1 for your dev subdomain.
### Microsoft (Entra / Azure AD)
1. Go to [Azure Portal > App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps)
2. Create or edit an app registration
3. Under **Authentication**, add a **Web** redirect URI: `https://myapp.dev/api/auth/callback/azure-ad`
Microsoft allows `http://localhost` with any port for development. It also accepts `.localhost` subdomains in most cases. Using a custom TLD with portless is still recommended for consistency across providers.
### Facebook (Meta)
1. Go to [Meta for Developers > App Dashboard](https://developers.facebook.com/apps/)
2. Under **Facebook Login > Settings**, add the portless URL to **Valid OAuth Redirect URIs**: `https://myapp.dev/api/auth/callback/facebook`
Facebook requires each redirect URI to be registered exactly (no wildcards). Strict Mode (enabled by default) enforces exact matching.
### GitHub
1. Go to [GitHub Developer Settings > OAuth Apps](https://github.com/settings/developers)
2. Set **Authorization callback URL**: `https://myapp.dev/api/auth/callback/github`
GitHub is permissive with localhost and subdomains. A custom TLD is not strictly required but keeps the setup consistent.
## Auth Library Configuration
### NextAuth / Auth.js
Set `NEXTAUTH_URL` to match the portless domain:
```env
NEXTAUTH_URL=https://myapp.dev
```
NextAuth uses this to construct callback URLs. Without it, callbacks may use `localhost` and cause a mismatch.
### Passport.js
Set the `callbackURL` in each strategy to use the portless domain:
```js
new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.BASE_URL + "/auth/google/callback",
});
```
Set `BASE_URL=https://myapp.dev` in your environment.
### Generic / Manual
Read the `PORTLESS_URL` environment variable that portless injects into the child process:
```js
const baseUrl = process.env.PORTLESS_URL || "http://localhost:3000";
const callbackUrl = `${baseUrl}/auth/callback`;
```
## Troubleshooting
### "redirect_uri_mismatch" or "invalid redirect URI"
The redirect URI sent during the OAuth flow doesn't match what's registered with the provider. Check:
1. The provider's registered redirect URI matches the portless domain exactly (protocol, host, path)
2. `NEXTAUTH_URL` or equivalent is set to the portless URL (not `localhost`)
3. The proxy is running with the correct TLD (`portless list` to verify)
### Provider requires HTTPS
`.dev` and `.app` TLDs are HSTS-preloaded, so browsers force HTTPS. Start the proxy:
```bash
portless proxy start --tld dev
```
Portless defaults to HTTPS on port 443 (auto-elevates with sudo). Run `portless trust` to add the local CA to your system trust store and eliminate browser warnings.
### Apple rejects the domain
Apple may require the domain to resolve publicly. Add a DNS A record for your dev subdomain pointing to `127.0.0.1`:
```
myapp.local.yourcompany.dev A 127.0.0.1
```
Or use a wildcard: `*.local.yourcompany.dev A 127.0.0.1`.
### Callback goes to wrong URL after sign-in
The auth library is constructing the callback URL from `localhost` instead of the portless domain. Set the appropriate environment variable:
- **NextAuth**: `NEXTAUTH_URL=https://myapp.dev`
- **Auth.js v5**: `AUTH_URL=https://myapp.dev`
- **Manual**: `PORTLESS_URL` is injected automatically; use it as the base URL
## Example
See [`examples/google-oauth`](../../examples/google-oauth) for a complete working example with Next.js + NextAuth + Google OAuth using `--tld dev`.