npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@enk0ded/portless

v0.14.1002

Published

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

Readme

@enk0ded/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 @enk0ded/portless

Or as a project dev dependency:

npm install -D @enk0ded/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.

This README describes the ENK0DED fork published as @enk0ded/portless. The CLI command is still portless, but install, release, and local dependency checks use the scoped package name.

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 in the 4000 to 4999 range that is free on 127.0.0.1 is assigned via the PORT environment variable. Portless skips browser-blocked ports during auto-assignment and rejects them for fixed app ports. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore PORT (Vite, VitePlus, VitePress, Astro, React Router, Angular, Laravel, Expo, React Native, Wrangler), portless auto-injects the right --port flag and, when needed, a matching --host flag or Wrangler's --ip flag.

For other tools, use exact command placeholders. Portless replaces whole arguments matching {PORT}, {HOST}, or {PORTLESS_URL} after assigning the app port and before spawning the child process. When any placeholder is present, automatic framework flag injection is skipped.

portless run my-server --port {PORT} --host {HOST} --url {PORTLESS_URL}

When auto-starting, portless reuses the configuration (port, TLS, suffix) 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 or .config/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 config file 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. Paths in apps are always relative to the repo root, even if the config file lives in .config/.

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.

In linked git worktrees, workspace URLs also get the branch prefix. For example, apps/web on branch fix-ui becomes fix-ui.web.myorg.localhost, and an apps override such as api.myapp becomes fix-ui.api.myapp.localhost.

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. Browser-blocked ports are rejected. | | 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 config file, 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" }
}

Lookup order is:

  1. portless.json
  2. .config/portless.json
  3. package.json "portless" key

For workspace package overrides, the package's own package.json "portless" key takes precedence over the root config's apps entry 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 bun run dev:app (or npm/yarn/pnpm) through the proxy. No changes to turbo.json are needed.

bun dev at the root works through turbo as usual. People without portless can run bun 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 config file, 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 bun 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). Wildcard routing is local proxy behavior only; mDNS LAN mode cannot resolve wildcard subdomains on other devices. To change wildcard mode for a running proxy, stop it and start it again with the desired mode.

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.

Monorepo workspace apps use the same prefixing rule, including names set through the root apps map.

Custom Suffixes

By default, portless uses the localhost suffix, which produces URLs like https://myapp.localhost and auto-resolves to 127.0.0.1 in most browsers. This fork uses "suffix" terminology because the value can be more than a single top-level label.

For one-off proxy starts, prefer --suffix:

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

For shell or service configuration, prefer PORTLESS_SUFFIX:

PORTLESS_SUFFIX=test portless proxy start
portless myapp next dev
# -> https://myapp.test

PORTLESS_SUFFIX accepts a single label such as test and dotted suffixes such as server01.acme.com:

PORTLESS_SUFFIX=server01.acme.com portless proxy start
portless myapp next dev
# -> https://myapp.server01.acme.com

PORTLESS_SUFFIX is read before the legacy PORTLESS_TLD variable. If both are set, PORTLESS_SUFFIX wins. PORTLESS_TLD and --tld remain supported so existing upstream-style environments and scripts keep working.

Suffix values are lowercased and validated as DNS labels: each label may contain lowercase letters, digits, and hyphens, must start and end with a letter or digit, and must be 63 characters or less. Leading dots, trailing dots, and consecutive dots are rejected.

Auto-elevated proxy starts pass the resolved PORTLESS_STATE_DIR through sudo, so a root-owned proxy uses the same per-user state and suffix settings as the command that started it. Set PORTLESS_STATE_DIR explicitly before running portless if you want a separate proxy state directory.

The proxy auto-syncs /etc/hosts for route hostnames, so .test, .server01.acme.com, and other configured suffixes resolve on your machine.

Recommended: .test for throwaway local names because it is IANA-reserved. Use a subdomain you control, such as local.example.com, when OAuth providers or other external systems require a public suffix. Avoid .local outside LAN mode because it conflicts with mDNS/Bonjour. Avoid bare public suffixes like .dev unless you understand the collision and HSTS implications.

How it works

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

    Browser --> Proxy
    Proxy --> App1
    Proxy --> App2
  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.

Portless also supports modern browser HMR WebSockets over HTTP/2 using RFC 8441 Extended CONNECT, so Next.js Turbopack, Vite, and similar dev servers can keep hot reloading while HTTPS/HTTP/2 is enabled.

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. In WSL, portless also installs the CA into the Windows CurrentUser Root store so Windows browsers trust WSL-served portless HTTPS URLs.

Trust the CA on other devices

portless trust installs the CA on the machine running portless. To trust it on another device — a phone testing over LAN mode, a second machine, or a browser with its own trust store — open the certificate page the proxy serves in the browser:

https://cert.localhost          # or cert.<suffix> for a custom suffix

The page offers the public CA certificate for download (/portless-ca.pem), shows its SHA-256 fingerprint so you can verify it, and gives step-by-step install instructions for macOS, Linux, Windows, and Firefox. Only the public certificate is ever served — the CA private key never leaves the machine that generated it. Install a CA only on devices you control: it can vouch for any HTTPS site to that device.

h2c and gRPC upstreams

By default, portless forwards each route to the local app with HTTP/1.1. Use --h2c only when the upstream app expects HTTP/2 cleartext, such as a local gRPC service:

portless grpc --h2c bun run grpc-server
portless alias grpc 50051 --h2c
PORTLESS_H2C=1 portless run bun run grpc-server

--h2c changes only the loopback connection from portless to the local app. Browser-facing HTTPS, route names, sharing modes, and loopback-only child app binding keep their existing defaults. There is no automatic protocol probing.

Path-based routes

By default, each hostname routes from /. Use --path <prefix> when you want multiple local apps under one hostname, such as an API and docs service:

portless myapp --path /api bun run api
portless myapp --path /docs bun run docs
portless alias myapp 4100 --path /legacy
PORTLESS_PATH=/api portless run bun run api

Path routing is explicit and boundary-aware. /api matches /api and /api/users, but not /api-v2. Portless forwards the full request path unchanged, so the upstream app still receives /api/users.

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, --suffix, --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. Custom service suffixes are persisted as PORTLESS_SUFFIX; --tld remains accepted as a compatibility alias. portless service status reports the installed port, HTTPS mode, configured suffix, 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, suffix, 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, or suffix 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:

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

  • Laravel / Wrangler: Laravel's php artisan serve gets --port and --host 127.0.0.1. Wrangler gets --port and --ip 127.0.0.1 because Wrangler's --host option means route hostname, not bind address.

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 bun start     # -> https://devbox.ts.net:8443

Use --tailscale-service for a stable Tailscale Service MagicDNS name:

portless myapp --tailscale-service next dev
# -> https://myapp.yourteam.ts.net

portless myapp --tailscale-service --tailscale-service-name api next dev
# -> https://api.yourteam.ts.net

Tailscale Services are tailnet-scoped and do not imply Tailscale Funnel. They require a tagged device identity and may need admin approval before MagicDNS resolves. Portless records pending approval in portless list instead of claiming the URL is reachable.

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, --tailscale-service, 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 MagicDNS and Tailscale HTTPS certificates enabled on the active tailnet.

ngrok sharing

Expose your dev server to the public internet with ngrok:

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

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

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

Public tunnel aliases

Use managed tunnels when a public HTTPS URL should route through the portless proxy instead of directly to a child app:

portless myapp --tunnel cloudflare next dev
# -> https://myapp.localhost
# -> https://abc.trycloudflare.com

portless myapp --tunnel ngrok next dev
# -> https://abc123.ngrok.app

Managed tunnel requests are public internet traffic. Portless keeps the child app bound to 127.0.0.1, starts the tunnel to the local proxy, and registers an exact tunnel alias for the generated public hostname. Unknown public Host headers are rejected; portless does not route arbitrary tunnel hostnames to the only running app.

Use manual tunnel aliases when another tool already provides the public hostname:

portless tunnel map myapp abc.trycloudflare.com
portless tunnel map myapp public.example.com --path /api
portless tunnel list
portless tunnel unmap abc.trycloudflare.com

Cloudflare support uses Cloudflare Quick Tunnels through the cloudflared CLI. ngrok managed tunnels require the ngrok CLI and authentication. --tunnel-hostname <hostname> requests a provider-specific stable hostname when supported; Cloudflare Quick Tunnels do not support this mode.

NetBird sharing

Expose your dev server to the public internet with NetBird Peer Expose:

portless myapp --netbird next dev
# -> https://myapp.localhost           (local)
# -> https://myapp-a1b2c3.netbird.cloud (public)

NetBird URLs are public reverse proxy URLs. Use --netbird-password, --netbird-pin, or --netbird-groups to restrict access:

portless myapp --netbird --netbird-groups team next dev
portless myapp --netbird-password secret --netbird-pin 123456 next dev

Set PORTLESS_NETBIRD=1 in your shell profile or .env to enable NetBird by default when portless runs an app. Setting PORTLESS_NETBIRD_PASSWORD, PORTLESS_NETBIRD_PIN, or PORTLESS_NETBIRD_GROUPS also enables NetBird sharing. portless list shows both local and NetBird URLs. The NetBird expose process is cleaned up automatically when the app exits.

Portless keeps the child app bound to 127.0.0.1 by default, even when NetBird sharing is active. The NetBird reverse proxy reaches the assigned local app port while the app itself is not opened on every network interface by portless.

Requires the NetBird CLI to be installed and connected, with Peer Expose enabled and allowed for the active peer.

Background apps

Use portless bg start when a dev server should keep running after the shell command returns, such as agent workflows or long-lived local services:

portless bg start --name web bun run dev
portless bg status web
portless bg logs web --tail 100
portless bg stop web

Background app management currently supports macOS and Linux. Windows support needs a separate process-group implementation.

bg start waits up to 30 seconds for the route to become ready by default. Use --wait <seconds> to customize the timeout, --no-wait to return immediately after spawning, or --keep to leave a timed-out child running for manual inspection. A timed-out child is stopped by default so stale routes are not left behind accidentally.

Background apps preserve the normal portless run feature set, including --path, --h2c, --tunnel, --tailscale-service, --ngrok, and --netbird. Backgrounding is not a sharing feature by itself: child apps still bind to loopback by default, and public exposure still requires explicit sharing flags.

Private stdout, stderr, and lifecycle logs are stored under ${PORTLESS_STATE_DIR:-~/.portless}/bg/logs with owner-only permissions. Prefer portless bg logs over reading those files directly because logs can contain tokens, cookies, request bodies, stack traces, or private URLs.

Use portless bg list to see all registered background apps, portless bg status [name] for one app, portless bg logs [name] for logs, portless bg restart [name] to restart from the stored command intent, and portless bg clean [name|--all] to remove dead entries and their logs. portless clean stops registered background apps before removing state, and portless prune removes dead background entries without stopping live ones.

Local dashboard

While the proxy runs it serves a small dashboard in the browser:

https://portless.localhost      # or portless.<suffix> for a custom suffix

The dashboard lists every running app with its hostname, local port, and any public exposure (Tailscale, Funnel, ngrok, managed tunnels, NetBird), plus the proxy's suffix, protocol, and CA trust status. It links straight to the certificate page when the CA is not yet trusted.

It is intentionally read-only: it never controls processes from the browser (no cross-origin control surface). Each row opens the app or copies its URL with one click. The hostname is reserved and cannot be claimed by an app. The dashboard URL is printed when the proxy starts; disable the dashboard with PORTLESS_DASHBOARD=0.

Multiplexed hostnames

Use --multiplex to let several apps share one hostname — for example two git worktrees of the same project, both reachable at https://myapp.localhost:

# from worktree A
portless myapp --multiplex -- npm run dev
# from worktree B
portless myapp --multiplex --label hotfix -- npm run dev

Each app gets a --label (default: the current directory name). Opening the shared hostname shows a portless app picker; your choice is remembered per hostname via a host-scoped cookie, and you can switch any time at https://myapp.localhost/__portless/switch. WebSocket connections follow the same cookie.

portless never modifies your app's HTML or response headers — the selection lives entirely in portless's own picker and redirect responses, so auth flows, cookies, and content stream through untouched. Without --multiplex, a hostname has a single owner exactly as before; the two modes cannot be mixed without --force. Env equivalents: PORTLESS_MULTIPLEX=1 and PORTLESS_LABEL=<label>.

Commands

portless                        # Run dev script through proxy
portless                        # From monorepo root: run all workspace packages
portless run [--name <name>] [cmd] [args...]  # Infer name, run through proxy
portless <name> <cmd> [args...]  # Run app at https://<name>.localhost
portless alias <name> <port>     # Register a static route (e.g. for Docker)
portless alias <name> <port> --force  # Overwrite an existing route
portless alias <name> <port> --h2c  # Register an HTTP/2 cleartext route
portless alias <name> <port> --path /api  # Register a path-scoped route
portless alias --remove <name>   # Remove a static route
portless tunnel map <route> <host>  # Map an exact public tunnel host
portless tunnel list             # Show public tunnel aliases
portless tunnel unmap <host>     # Remove a public tunnel alias
portless bg start [cmd]          # Start an app in the background
portless bg status [name]        # Show background app status
portless bg list                 # List background apps
portless bg logs [name]          # Print background app logs
portless bg stop [name]          # Stop a background app
portless bg restart [name]       # Restart a background app
portless bg clean [name]         # Remove dead background entries
portless get <name>              # Print URL for a service
portless get <name> --json       # Print service info as JSON
portless url <name>              # Alias for portless get
portless list                    # Show active routes
portless list --json             # Show active routes as JSON
portless ls                      # Alias for portless list
portless status                  # Alias for portless list
portless trust                   # Add local CA to system trust store
portless clean                   # Remove state, CA trust entry, and hosts block
portless prune                   # Kill orphaned dev servers from crashed sessions
portless completion <shell>      # Print shell completion script (bash, zsh, fish)
portless hosts sync              # Add routes to /etc/hosts (fixes Safari)
portless hosts clean             # Remove portless entries from /etc/hosts

# Disable portless (run command directly)
PORTLESS=0 bun dev               # Bypasses proxy, uses default port

# Child env assignments
portless myapp API_URL=1 next dev # Pass API_URL only to the child command
portless grpc --h2c bun grpc.js   # Proxy to an h2c or gRPC upstream
portless myapp --path /api bun api.js  # Route only /api to this app
portless myapp --tunnel cloudflare bun dev # Public Quick Tunnel URL

# Proxy control
portless proxy start             # Start the HTTPS proxy (port 443, daemon)
portless proxy start --no-tls    # Start without HTTPS (port 80)
portless proxy start --lan       # Start in LAN mode (mDNS .local for devices)
portless proxy start -p 1355     # Start on a custom port (no sudo)
portless proxy start --suffix test  # Use .test instead of .localhost
portless proxy start --tld test  # Compatibility alias for --suffix
portless proxy start --foreground  # Start in foreground for debugging
portless proxy start --wildcard  # Allow unregistered subdomains to fall back to parent
portless proxy stop              # Stop the proxy

# OS startup service
portless service install         # Start HTTPS proxy when the OS starts
portless service install --lan   # Start service in LAN mode
portless service install --wildcard  # Persist wildcard routing in the service
portless service status          # Show service and proxy status
portless service uninstall       # Remove the startup service

Options

-p, --port <number>              Port for the proxy (default: 443, or 80 with --no-tls)
--no-tls                         Disable HTTPS (use plain HTTP on port 80)
--https                          Enable HTTPS (default, accepted for compatibility)
--lan                            Enable LAN mode (mDNS .local for real devices)
--ip <address>                   Pin a specific LAN IP (disables auto-follow; use with --lan)
--cert <path>                    Use a custom TLS certificate
--key <path>                     Use a custom TLS private key
--foreground                     Run proxy in foreground instead of daemon
--suffix <suffix>                Use a custom suffix instead of .localhost
--tld <tld>                      Compatibility alias for --suffix
--wildcard                       Allow unregistered subdomains to fall back to parent route locally
                                 Proxy-level only; restart proxy to change this mode
--state-dir <path>               Use a custom state directory with service install
--script <name>                  Run a specific package.json script (default: dev)
--app-port <number>              Use a fixed app port; browser-blocked ports are rejected
--h2c                            Forward this route to an HTTP/2 cleartext upstream
--multiplex                      Share this hostname with other apps (shows an app picker)
--label <label>                  Label for this multiplexed app (default: directory name)
--path <prefix>                  Scope this route to a path prefix, e.g. /api
--tunnel <provider>              Share publicly via a managed tunnel: cloudflare or ngrok
--tunnel-hostname <hostname>     Request a provider-specific stable tunnel hostname
--tailscale                      Share the app on your Tailscale network (tailnet)
--tailscale-service              Share the app as a stable Tailscale Service
--tailscale-service-name <name>  Use an explicit Tailscale Service name
--funnel                         Share the app publicly via Tailscale Funnel
--ngrok                          Share the app publicly via ngrok
--netbird                        Share the app publicly via NetBird Peer Expose
--netbird-password <string>      Require a password for the NetBird public URL
--netbird-pin <code>             Require a PIN for the NetBird public URL
--netbird-groups <csv>           Restrict the NetBird URL to user groups
bg start --wait [seconds]        Wait for background readiness (default: 30)
bg start --no-wait               Return after spawning a background app
bg start --keep                  Keep a timed-out background app running
bg logs --tail <lines>           Print the last N background log lines
bg stop --force                  Force-stop the exact registered background app
--force                          Kill the existing process and take over its route
--name <name>                    Use <name> as the app name

Environment variables

# Configuration
PORTLESS_PORT=<number>           Override the default proxy port
PORTLESS_APP_PORT=<number>       Use a fixed app port (same as --app-port)
PORTLESS_H2C=1                   Forward app routes to HTTP/2 cleartext upstreams
PORTLESS_MULTIPLEX=1             Share the hostname with other apps (same as --multiplex)
PORTLESS_LABEL=<label>           Label for this multiplexed app (same as --label)
PORTLESS_DASHBOARD=0             Disable the dashboard at portless.<suffix>
PORTLESS_PATH=<prefix>           Scope app routes to a path prefix
PORTLESS_TUNNEL=<provider>       Share publicly via a managed tunnel provider
PORTLESS_TUNNEL_HOSTNAME=<host>  Request a provider-specific stable tunnel hostname
PORTLESS_HTTPS=0                 Disable HTTPS (same as --no-tls)
PORTLESS_LAN=1                   Enable LAN mode when set to 1 (auto-detects LAN IP)
PORTLESS_LAN_IP=<address>        Pin a specific LAN IP for LAN mode
PORTLESS_SUFFIX=<suffix>         Use a custom suffix (e.g. test, acme.com; default: localhost)
PORTLESS_TLD=<tld>               Compatibility alias for PORTLESS_SUFFIX
PORTLESS_WILDCARD=1              Allow unregistered subdomains to fall back to parent route
PORTLESS_SYNC_HOSTS=0            Disable auto-sync of /etc/hosts (on by default)
PORTLESS_TAILSCALE=1             Share apps on your Tailscale network (same as --tailscale)
PORTLESS_TAILSCALE_SERVICE=1     Share apps as Tailscale Services
PORTLESS_TAILSCALE_SERVICE_NAME=<name>
                                  Use an explicit Tailscale Service name
PORTLESS_FUNNEL=1                Share apps publicly via Tailscale Funnel (same as --funnel)
PORTLESS_NGROK=1                 Share apps publicly via ngrok (same as --ngrok)
PORTLESS_NETBIRD=1               Share apps publicly via NetBird (same as --netbird)
PORTLESS_NETBIRD_PASSWORD=<s>    Require a password for the NetBird public URL
PORTLESS_NETBIRD_PIN=<code>      Require a PIN for the NetBird public URL
PORTLESS_NETBIRD_GROUPS=<csv>    Restrict the NetBird URL to user groups
PORTLESS_STATE_DIR=<path>        Override the state directory

# Injected into child processes
KEY=value before <cmd>            Adds an env var only to the child command
PORT                             Ephemeral port the child should listen on
HOST                             Usually 127.0.0.1 (omitted for Expo in LAN mode)
PORTLESS_URL                     Public URL (e.g. https://myapp.localhost)
PORTLESS_TAILSCALE_URL           Tailscale URL of the app (when --tailscale is active)
PORTLESS_TAILSCALE_SERVICE_URL   Tailscale Service URL of the app
PORTLESS_NGROK_URL               ngrok URL of the app (when --ngrok is active)
PORTLESS_TUNNEL_URL              Managed tunnel URL of the app (when --tunnel is active)
PORTLESS_NETBIRD_URL             NetBird URL of the app (when --netbird is active)
NODE_EXTRA_CA_CERTS              Path to the portless CA (when HTTPS is active)

Command args can use exact placeholders {PORT}, {HOST}, and {PORTLESS_URL}. Portless replaces only whole-argument matches. For example, {PORT} is replaced, but --port={PORT} is left unchanged.

Prefer PORTLESS_SUFFIX for new configuration. It accepts single-label suffixes such as test and dotted suffixes such as acme.com or server01.acme.com. PORTLESS_TLD is only a compatibility alias and is ignored when PORTLESS_SUFFIX is set.

Reserved names: run, get, url, alias, tunnel, hosts, list, ls, status, trust, clean, prune, proxy, bg, service, and completion 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.

Shell completion

# Bash
source <(portless completion bash)

# Zsh
eval "$(portless completion zsh)"

# Fish
mkdir -p ~/.config/fish/completions
portless completion fish > ~/.config/fish/completions/portless.fish

Programmatic API

For Node-based config files such as Playwright, Vite proxy config, or next.config.js, import getUrl to resolve service URLs without spawning the CLI:

import { getUrl } from "@enk0ded/portless";

const cms = await getUrl("cms");
// cms.url      -> "https://cms.localhost"
// cms.hostname -> "cms.localhost"
// cms.port     -> 443
// cms.tls      -> true

await fetch(`${cms}/api/health`);

// Skip worktree prefixes for stable callback URLs.
const stable = await getUrl("cms", { worktree: false });

getUrl() uses the same hostname and worktree logic as portless get, reading port, TLS, and suffix from the active proxy's persisted state. The returned object JSON-serializes to { url, hostname, port, tls, tld }.

Uninstall / reset

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

portless clean

macOS/Linux may prompt for sudo. Custom certificate paths passed with --cert and --key are not deleted.

Safari / DNS

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

If Safari can't find your .localhost URL:

portless hosts sync    # Add current routes to /etc/hosts
portless hosts clean   # Clean up later

Auto-syncs /etc/hosts for route hostnames by default (.localhost, custom suffixes, LAN .local). Set PORTLESS_SYNC_HOSTS=0 to disable.

Proxying Between Portless Apps

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

Vite (vite.config.ts):

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

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

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

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

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

Development

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

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

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

Fork Maintenance

This fork intentionally differs from upstream in a few areas:

  • Published package identity is @enk0ded/portless; the command remains portless.
  • Fork releases use high patch ranges, such as 0.14.1000, to stay semver-compatible without colliding with upstream versions.
  • Repository development uses Bun workspaces, bun.lock, Bun-powered CI, and Bun-based Windows debugging scripts.
  • Custom domain configuration uses suffix terminology. PORTLESS_SUFFIX is the preferred environment variable, dotted suffixes are supported, and PORTLESS_TLD remains a compatibility alias.
  • Local dependency detection checks node_modules/@enk0ded/portless so one-off npx or pnpm dlx downloads are still rejected.

See FORK.md for the full list of fork-owned invariants and the upstream sync checklist.

Requirements

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