MCPcopy Index your code
hub / github.com/vercel-labs/portless

github.com/vercel-labs/portless @v0.15.1 sqlite

repository ↗ · DeepWiki ↗ · release v0.15.1 ↗
542 symbols 1,711 edges 120 files 60 documented · 11% 2 cross-repo links
README

portless

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

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

Install

Global (recommended):

npm install -g portless

Or as a project dev dependency:

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

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.

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.

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

Configuration

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

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

Use an optional portless.json to override defaults:

{ "name": "myapp" }
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):

{
  "apps": {
    "apps/web": { "name": "myapp" },
    "apps/api": { "name": "api.myapp" }
  }
}
portless        # from repo root: starts all workspace packages with a "dev" script
cd apps/web && portless   # start just one 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:

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

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

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

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

--script flag

Override the default script for a single invocation:

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

Turborepo

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

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

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

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

Use in package.json

You can still use portless in package.json scripts:

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

With a portless.json, you can simplify to:

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

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

Subdomains

Organize services with subdomains:

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

Git Worktrees

portless run automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain so each worktree gets its own URL without any config changes:

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

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

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

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:

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:

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

How it works

flowchart TD
    Browser["Browser

myapp.localhost"]
    Proxy["portless proxy

(port 80 or 443)"]
    App1[":4123

myapp"]
    App2[":4567

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

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.

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

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

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

# If you skipped the trust prompt on first run, trust the CA later
portless 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.

Start at OS startup

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

portless service install
portless service install --lan
portless service install --wildcard
PORTLESS_STATE_DIR=~/.portless-lan PORTLESS_LAN=1 portless service install
portless service status
portless service 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

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

--lan switches the proxy to mDNS discovery: services are advertised as <name>.local and reachable from any device on the same network. Portless auto-detects your LAN IP and follows Wi-Fi/IP changes automatically, but you can pin another address with --ip <address> or by exporting PORTLESS_LAN_IP. Set PORTLESS_LAN=1 in your shell (0/1 boolean) to make LAN mode the default whenever the proxy starts.

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

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

Framework notes

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

js // next.config.js module.exports = { allowedDevOrigins: ["myapp.local", "*.myapp.local"], };

  • Expo / React Native: portless always injects --port. React Native also gets --host 127.0.0.1. Expo gets --host localhost outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing --host or HOST.

Tailscale sharing

Share your dev server with teammates on your Tailscale network:

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

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

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

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

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

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

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

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

ngrok sharing

Expose your dev server to the public internet with ngrok:

```bash portless myapp --ngrok next dev

-> https://myapp.localhost (local)

-> https://abc123.ngrok.app (public)

Extension points exported contracts — how you extend this code

LinuxCATrustConfig (Interface)
* Linux distro CA trust configuration. * Each entry maps a distro family to its CA certificate directory and update com
packages/portless/src/certs.ts
CodeProps (Interface)
(no doc)
apps/docs/src/components/code.tsx
TestState (Interface)
(no doc)
tests/e2e/src/zombie.test.ts
MultiAppEntry (Interface)
* Multi-app mode: discover workspace packages and run all that have * the target script through the proxy concurrently.
packages/portless/src/cli.ts
AppResult (Interface)
(no doc)
tests/e2e/src/parallel.test.ts
ManifestEntry (Interface)
(no doc)
packages/portless/src/turbo.ts
E2EContext (Interface)
(no doc)
tests/e2e/src/harness.ts
InferredName (Interface)
(no doc)
packages/portless/src/auto.ts

Core symbols most depended-on inside this repo

on
called by 130
packages/portless/src/ngrok.ts
add
called by 47
packages/portless/src/cli.ts
createProxyServer
called by 46
packages/portless/src/proxy.ts
injectFrameworkFlags
called by 41
packages/portless/src/cli-utils.ts
kill
called by 36
packages/portless/src/ngrok.ts
loadRoutes
called by 35
packages/portless/src/routes.ts
parseHostname
called by 32
packages/portless/src/utils.ts
addRoute
called by 30
packages/portless/src/routes.ts

Shape

Function 479
Interface 32
Method 19
Class 10
Route 2

Languages

TypeScript99%
Python1%

Modules by API surface

packages/portless/src/cli.ts80 symbols
packages/portless/src/service.ts51 symbols
packages/portless/src/cli-utils.ts47 symbols
packages/portless/src/tailscale.ts34 symbols
packages/portless/src/certs.ts33 symbols
packages/portless/src/config.ts22 symbols
packages/portless/src/routes.ts21 symbols
packages/portless/src/ngrok.ts20 symbols
packages/portless/src/auto.ts12 symbols
tests/e2e/src/harness.ts11 symbols
packages/portless/src/workspace.ts11 symbols
packages/portless/src/mdns.ts10 symbols

Used by 2 indexed graphs manifest dependencies, hub-wide

Dependencies from manifests, versioned

@ai-sdk/react3.0.96 · 1×
@angular/build21.1.4 · 1×
@angular/cli21.1.4 · 1×
@angular/common21.1.5 · 1×
@angular/compiler21.1.5 · 1×
@angular/core21.1.5 · 1×
@angular/platform-browser21.1.5 · 1×
@angular/platform-browser-dynamic21.1.5 · 1×
@base-ui/react1.3.0 · 1×
@eslint/js9.39.2 · 1×
@hono/node-server1.19.9 · 1×
@mdx-js/loader3 · 1×

For agents

$ claude mcp add portless \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact