# portless: named .localhost URLs instead of port numbers for local dev

> portless is a Vercel Labs CLI that puts a local HTTPS proxy in front of your dev server so apps answer on names like myapp.localhost instead of localhost:3000. It is useful for multi-app repos and for agents that need stable addresses, but the injection rules and the pre-1.0 state directory are the parts to check before you commit.

**vercel-labs/portless** — Replace port numbers with stable, named local URLs. For humans and agents.

- Repository: https://github.com/vercel-labs/portless
- Website: https://portless.sh
- Stars: 12,618 · Forks: 428
- Language: TypeScript
- License: Apache-2.0
- Published: 2026-08-08 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/vercel-labs-portless

## The problem portless solves, and who actually has it

Every dev server wants a port. The next one wants a different port. You end up with a notes file that says the API is on 3001 and the web app is on 3000, and that file goes stale the moment someone adds a third service. Cookies set on localhost are shared across everything running there, which is its own class of confusion. And when you hand the same machine to an agent, the agent has to be told the port, and the port may have moved since.

portless replaces the number with a name. The README's own diff is the clearest statement of intent: `"dev": "next dev"` becomes `"dev": "portless run next dev"`, and the app answers at https://myapp.localhost rather than http://localhost:3000. That is the whole pitch, and it is a real one for anyone running more than one service locally.

The audience is narrower than "all web developers". If you run a single Next.js app and never touch the port, portless adds a proxy, a CA and a state directory to your life for very little. It earns its place in monorepos with several workspace packages, in setups where a browser needs a stable origin for OAuth redirects, and in agent workflows where the address has to be knowable in advance. The repository description says as much: for humans and agents.

## How the proxy, the port assignment and the flag injection fit together

Running an app through portless starts a proxy if one is not already up, then runs your command as a child process. The proxy picks a random port in the 4000-4999 range and hands it to the child through the PORT environment variable. Frameworks that read PORT (the README names Next.js, Express and Nuxt) need nothing else.

Frameworks that ignore PORT are the interesting case. For Vite, VitePlus, Astro, React Router, Angular, Expo and React Native, portless appends the right --port flag itself, and a matching --host flag when one is needed. The injection only reaches a package script whose command starts with the framework or a known runner, and only for the framework's server commands: dev, serve, preview, start, a bare vite, or vite [root]. A command that builds rather than serves, such as vite build, vite optimize, vp test or astro check, rejects the flags and is left alone. Expo's connection modes (--localhost, --lan, --tunnel) survive while the port is still injected.

The refusals matter as much as the injections, and the README is explicit about them. Portless leaves a script alone when appending flags would not work: a compound command using &&, | or ;, a trailing # comment, its own -- terminator, an env prefix like NODE_ENV=production vite, delegation to another script such as "dev": "npm run dev:vite", or runner flags before the script name like bun run --bun dev. It also leaves alone a script it cannot classify, the README's example being vp --mode dev build, where a flag sits before the subcommand on a CLI whose grammar it does not track. Those scripts keep their own port, and the README's instruction is blunt: set it in the script yourself.

State lives in ~/.portless. When the proxy runs under sudo, it resolves that path from the invoking user's home so the proxy and the unprivileged app processes share the same route registrations. That detail is what makes the sudo elevation workable rather than a mess of two competing state directories.

## Install portless and route one app through it

The README recommends a global install, which is what you want if you plan to run portless from several repositories.

```bash
npm install -g portless
```

The alternative is a project dev dependency with `npm install -D portless`, and the README flags the trade-off: portless is pre-1.0, so per-project installs mean different contributors may run different versions, and the state directory format may change between releases, which can require re-running `portless trust`.

With the binary available, name the app and give it a command. The README's first example is:

```bash
portless myapp next dev
```

The output address is https://myapp.localhost. HTTPS with HTTP/2 is on by default. On first run portless generates a local CA, trusts it, and binds port 443, auto-elevating with sudo on macOS and Linux. If you do not want any of that, `--no-tls` gives you plain HTTP instead.

Bare `portless` is the shortest path once you are inside a project. It runs the "dev" script from package.json through the proxy and infers the app name from the package name, the git root or the directory. To pin the name, add a portless.json at the root:

```json
{ "name": "myapp" }
```

Then `portless` runs the dev script and serves https://myapp.localhost. If the script you want is not called dev, override it for one invocation with `portless --script start` or `portless --script test`.

## The monorepo config, and why the apps map is optional

One portless.json at the repository root covers every workspace package. Portless discovers packages from pnpm-workspace.yaml, or from the "workspaces" field in package.json for npm, yarn and bun. Running `portless` from the root starts all workspace packages that have a "dev" script; running it inside a package starts just that one.

The apps map exists for name overrides, not as a requirement:

```json
{
  "apps": {
    "apps/web": { "name": "myapp" },
    "apps/api": { "name": "api.myapp" }
  }
}
```

Packages you do not list still auto-discover, with hostnames following the `<package>.<project>.localhost` convention. The project segment comes from the most common npm scope across workspace packages, so @myorg/web and @myorg/api both produce myorg, falling back to the workspace root directory name. A package whose short name matches the project name gets the bare `<project>.localhost` without duplication.

If you would rather not keep a second config file, a "portless" key in package.json does the same job. A string is shorthand for the name, and an object supports the per-app fields name, script, appPort and proxy. Precedence runs package.json key, then portless.json app entries, then CLI flags on top. Beyond name and script, the config fields are appPort for a fixed child-process port, proxy as a boolean that is auto-detected by default, and turbo, which defaults to true and can be set false to use direct spawning instead of turborepo.

## Where portless quietly stops helping

The injection rules are the sharpest limitation, and they are not edge cases in real repositories. A dev script like `"dev": "NODE_ENV=development vite"` is an env prefix, so portless leaves it alone and the app keeps whatever port it chose. Same for `"dev": "npm run dev:vite"`, which is delegation to another script. Same for `"dev": "vite dev && tail -f log"`, which is a compound command. In each of those, you are back to setting the port by hand, and the named URL only works if the proxy can still reach the process, which is a question the README does not answer for the unclassified case.

The pre-1.0 warning is the second thing to weigh. The README states plainly that the state directory format may change between releases and that this can require re-running `portless trust`. If you install per-project, contributors on different versions can disagree about that format. A global install narrows the problem but does not remove it, because global installs also drift.

Non-interactive environments are handled deliberately rather than gracefully: with no TTY, or with CI=1, portless exits with a descriptive error instead of prompting. That is the right call for a task runner that should fail early, but it means portless is not something you can drop into a CI job that expects a dev server to come up unattended. The README does not document a rollback path for the CA it installs or for the state directory, so plan for that manually if you try it and change your mind.

## How portless compares with a reverse proxy you configure yourself

The obvious alternative is doing this by hand: run Caddy, nginx or a small Node proxy, write a vhost per app, generate a certificate with mkcert, and add /etc/hosts entries. That approach is more work up front and gives you more control. You decide the TLD, the certificate lifetime, the routing rules and where state lives. Nothing changes under you between releases, because you wrote it.

The difference in approach is that portless owns the whole path: it starts the proxy, generates and trusts the CA, binds 443, assigns the child port, injects framework flags, and infers names from your workspace layout. A hand-rolled Caddy setup does none of the process management. It will not know that your Vite script needs --port, and it will not restart itself because you ran a command in a new terminal. If your objection to portless is that you do not want a tool rewriting your dev scripts, Caddy plus mkcert is the honest answer, and it costs you a config file per app.

A narrower alternative for the name resolution alone is editing /etc/hosts and running the app on a fixed port. That works, and it is what many people already do for OAuth callbacks. It gives you no HTTPS and no port assignment, so it solves the naming half of the problem and none of the rest.

## Licence, maintenance and what the release cadence tells you

portless is Apache-2.0, which permits commercial use and modification and includes an explicit patent grant. That is a permissive licence with no copyleft obligation on your own code. The CA that portless generates and installs into your trust store is a separate matter from the licence, and the README does not describe how to remove it.

The repository is not archived, and the last push was on 2026-08-24, which is recent enough that the project is being worked on. The release history shows v0.15.6 on 2026-08-24, v0.15.5 on 2026-07-30 and v0.15.4 on 2026-07-16, so roughly two to four weeks between patch releases. That cadence is consistent with the pre-1.0 warning: patches land often, and the version number is still 0.x. The monorepo's own package.json requires Node >=24 and pnpm >=11 <12 for development, so if you intend to build from source rather than install the published package, that is your floor.

Upgrade cost is mostly the trust step. If the state directory format changes, the README says you may need to re-run `portless trust`, which on macOS and Linux means another sudo elevation for port 443. Budget for that after upgrades rather than treating it as a one-time setup.

## Conclusion

Adopt portless if you juggle several dev servers, run an agent that needs a fixed address, or want HTTPS on .localhost without hand-rolling certificates; skip it if you need a stable on-disk state format, since the README warns the state directory may change between releases and force a re-run of portless trust, and skip it if your dev script is a compound command or an env-prefixed invocation, because portless leaves those alone and they keep their own port. Before rolling it out, run one workspace package with portless --script start to confirm the flag injection reaches your framework, and check what lands in ~/.portless on a machine where the proxy runs under sudo, because that directory is shared with the unprivileged app processes.

## FAQ

### What does portless do?

It replaces port numbers with stable named .localhost URLs for local development. Running `portless myapp next dev` serves the app at https://myapp.localhost through a local proxy that assigns the child process a random port in the 4000-4999 range.

### What does portless mean?

In this project the name refers to running a dev server without a port number in the URL, since the proxy binds port 443 and routes by hostname. The README's own example turns http://localhost:3000 into https://myapp.localhost.

### What is portless?

portless is a TypeScript CLI from Vercel Labs, published to npm, that puts an HTTPS proxy with HTTP/2 in front of your dev command. It also injects the correct --port flag for frameworks such as Vite, Astro, Angular and Expo that ignore the PORT environment variable.

### What does portless mean in general?

The word describes a device or system with no ports. In this project it is used as a product name for a tool that removes port numbers from local development URLs, which is the sense the README relies on.

### Is iPhone 17 portless?

The material for this project does not cover Apple hardware, so there is no answer here. This page is about the portless CLI for local development URLs.

## Sources

- [Official documentation](https://portless.sh)
- [Official README](https://github.com/vercel-labs/portless#readme)
- [Project repository](https://github.com/vercel-labs/portless)
- [Release notes](https://github.com/vercel-labs/portless/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/vercel-labs-portless
