@enk0ded/portless
v0.14.1002
Published
Replace port numbers with stable, named .localhost URLs. For humans and agents.
Maintainers
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.localhostInstall
Global (recommended):
npm install -g @enk0ded/portlessOr as a project dev dependency:
npm install -D @enk0ded/portlessportless is pre-1.0. When installed per-project, different contributors may run different versions. The state directory format may change between releases, which can require re-running
portless trust.
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.localhostHTTPS with HTTP/2 is enabled by default. On first run, portless generates a local CA, trusts it, and binds port 443 (auto-elevates with sudo on macOS/Linux). Use --no-tls for plain HTTP.
The proxy auto-starts when you run an app. A random port 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>.localhostUse an optional portless.json or .config/portless.json to override defaults:
{ "name": "myapp" }portless # -> runs "dev" script, https://myapp.localhostThe script defaults to "dev". The name is inferred from package.json if not set in config.
Monorepo
One portless 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 packageThe 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:
portless.json.config/portless.jsonpackage.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.localhostBy default, only explicitly registered subdomains are routed (strict mode). Use --wildcard when starting the proxy to allow any subdomain of a registered route to fall back to that app (e.g. tenant1.myapp.localhost routes to the myapp app without extra registration). 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.localhostUse --name to override the inferred base name while keeping the worktree prefix:
portless run --name myapp next dev # -> https://fix-ui.myapp.localhostPut portless run in your package.json once and it works everywhere. The main checkout uses the plain name, each worktree gets a unique subdomain. No collisions, no --force.
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.testFor shell or service configuration, prefer PORTLESS_SUFFIX:
PORTLESS_SUFFIX=test portless proxy start
portless myapp next dev
# -> https://myapp.testPORTLESS_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.comPORTLESS_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- Start the proxy: auto-starts when you run an app, or start explicitly with
portless proxy start - Run apps:
portless <name> <command>assigns a free port and registers with the proxy - Access via URL:
https://<name>.localhostroutes through the proxy to your app
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 trustOn Linux, portless trust supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and openSUSE (via update-ca-certificates or update-ca-trust). On Windows, it uses certutil to add the CA to the system trust store. 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 suffixThe 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 apiPath 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 uninstallThe service uses portless defaults unless install options or PORTLESS_* environment variables are provided: HTTPS on port 443 with .localhost names. service install accepts the proxy options you would use with proxy start, including --port, --no-tls, --lan, --ip, --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
.localhostnames toallowedDevOrigins:// next.config.js module.exports = { allowedDevOrigins: ["myapp.local", "*.myapp.local"], };Expo / React Native: portless always injects
--port. React Native also gets--host 127.0.0.1. Expo gets--host localhostoutside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing--hostorHOST.Laravel / Wrangler: Laravel's
php artisan servegets--portand--host 127.0.0.1. Wrangler gets--portand--ip 127.0.0.1because Wrangler's--hostoption 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:8443Use --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.netTailscale 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.appManaged 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.comCloudflare 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 devSet 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 webBackground 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 suffixThe 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 devEach 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 serviceOptions
-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 nameEnvironment 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, andcompletionare subcommands and cannot be used as app names directly. Useportless run <cmd>to infer the name from your project, orportless --name <name> <cmd>to force any name including reserved ones.
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.fishProgrammatic 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 cleanmacOS/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 laterAuto-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 PrettierFork Maintenance
This fork intentionally differs from upstream in a few areas:
- Published package identity is
@enk0ded/portless; the command remainsportless. - 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_SUFFIXis the preferred environment variable, dotted suffixes are supported, andPORTLESS_TLDremains a compatibility alias. - Local dependency detection checks
node_modules/@enk0ded/portlessso one-offnpxorpnpm dlxdownloads 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
--tailscaleand--funnel) - ngrok CLI (optional, for
--ngrok) - NetBird CLI (optional, for
--netbird)
