Goodbye Port Chaos: Local Development with Portless
It's Tuesday morning, you start your frontend and the terminal says: "Port 3000 is already in use". Of course. The other project you only wanted to look at for a minute an hour ago is still running in the background. So you move to 3001. Shortly after, the API needs 3000 as well, the admin panel sits on 5173 and a Storybook is running somewhere on 6006.
After a few weeks with several projects you ask yourself a question that is actually ridiculous: which number belonged to which app again? Your browser history is nothing but localhost entries, your bookmarks are useless, and the README in your team says something like "runs on port 3000, unless it's taken".
This is exactly where Portless comes in. Instead of port numbers, every project gets a fixed, meaningful name, for example https://myapp.localhost. In this post we look at how it works, what it brings to your daily work and where its limits are.
Why ports annoy you more than you think
The problem is mundane, but it costs a little bit of focus every single day. Every framework ships with a default port: Next.js and Nuxt start on 3000, Vite on 5173, Angular on 4200, Astro on 4321, Storybook on 6006. If you run two projects with the same stack in parallel, collisions are inevitable.
The consequences go beyond the error message:
- Changing numbers: A project runs on 3000 today and on 3002 tomorrow because something was still open yesterday. Everything that knows the number breaks.
- Hardcoded configuration: OAuth redirect URIs, CORS settings, webhook targets and
.envfiles often contain fixed ports. When the port changes, the configuration has to change too. - Shared cookies: All apps on
localhostshare the same cookie scope, no matter which port they run on. A login in one project can overwrite the session in another. Bugs like this are hard to track down. - Mental load: You memorize mappings that carry no real information. "5173 is the dashboard" is knowledge nobody needs.
On top of that comes a new factor: AI agents that start dev servers locally and test them in the browser. They also need to know at which address an app is currently running. A stable name is easier for humans and agents alike than a number that can change.
The idea behind Portless
Portless solves the problem by taking ports out of your field of view. A local reverse proxy runs in the background and listens on port 443. Each app registers a hostname there, and the proxy forwards requests to the matching port.
Browser (myapp.localhost) -> Proxy (port 443) -> App (random port)
Portless picks the actual port itself, from the range 4000 to 4999, and passes it to the app through the PORT environment variable. You neither have to know it nor type it. The name stays stable, the number behind it is allowed to change.
The fact that this works without touching your system DNS is down to the .localhost domain ending. Names with this ending automatically resolve to 127.0.0.1 on current systems. So you need neither an entry in your hosts file nor a local DNS server.
One security aspect up front: by default the proxy only listens on the loopback addresses 127.0.0.1 and ::1. It is therefore not reachable from the network unless you explicitly enable LAN mode.
Installation and first start
The barrier to entry is low. You need Node.js 24 or newer, and macOS, Linux and Windows are supported. You install it with npm, preferably globally:
npm install -g portless
Alternatively you can add it as a dev dependency per project. But then different team members can end up with different versions, which is not entirely harmless for a tool before version 1.0 (more on that in the section on pitfalls).
After that, a single command in your project folder is enough:
portless
Portless reads the dev script from your package.json and starts it through the proxy. It derives the name from the package.json, the Git root or the folder name. If you want a different one, create a portless.json:
{ "name": "myapp" }
If you prefer to be explicit, pass the name and the command directly:
portless myapp next dev
# -> https://myapp.localhost
You can also write this into your package.json permanently, so everyone on the team uses the same command: "dev": "portless myapp next dev".
What happens on the first start: Portless creates a local certificate authority (CA), sets it up as trusted and binds port 443. On macOS and Linux it asks for administrator rights to do so. This happens once and is unavoidable for local HTTPS.
What about frameworks? Next.js, Express or Nuxt respect the PORT variable anyway. For Vite, Astro, Angular, Expo and others that ignore it, Portless automatically appends the right --port flag (and --host where needed). You don't have to touch your script for that.
Subdomains for multiple services
A project rarely consists of just one app. Usually there is a frontend, an API, maybe a docs site or an admin panel. With Portless every service gets a name under the project name:
portless api.myapp pnpm start
# -> https://api.myapp.localhost
portless docs.myapp next dev
# -> https://docs.myapp.localhost
This is more than cosmetics. The structure mirrors how the project is set up in production. A frontend that talks to the API at api.myapp.localhost is much closer to the later api.your-domain.com than a call to localhost:4000. That lets you test cookie domains, CORS rules and redirect URIs far more realistically.
And one more side effect: the cookie problems from above disappear. Every hostname has its own cookie scope, so the apps no longer get in each other's way.
Git worktrees without collisions
If you work with Git worktrees, for example to work on a bugfix and a feature in parallel, you know the problem in an even sharper form: both checkouts contain the same code and want the same port.
Portless detects worktrees automatically when you use portless run. In the main directory the name stays unchanged, in a linked worktree the branch name is prepended as a subdomain:
# Main worktree
portless run next dev # -> https://myapp.localhost
# Worktree on branch "fix-ui"
portless run next dev # -> https://fix-ui.myapp.localhost
You add portless run to your package.json once and it works everywhere. No per-branch configuration, no --force, no collisions. With --name you can override the base name when needed, the worktree prefix stays.
Especially if you let AI agents work in separate worktrees next to your own code, this is very handy. Every instance has its own predictable address.
Local HTTPS and your own domain ending
HTTPS is on by default with Portless, including HTTP/2. That is less obvious than it sounds. Many browser features (service workers, certain APIs, secure cookies) behave differently over HTTP than in production. If you work with HTTPS locally from the start, you discover such differences earlier. If you do need plain HTTP, switch with --no-tls.
By default Portless uses .localhost. But you can change the ending:
portless proxy start --tld test
portless myapp next dev
# -> https://myapp.test
The proxy maintains the hosts file for this itself, because .test, unlike .localhost, is not resolved automatically. Several endings at the same time are also possible by repeating --tld.
Particularly interesting: you can even use a domain you own as the "TLD", such as dev.example.com. Your app is then called https://myapp.dev.example.com locally. The URLs have the same structure as in production, and OAuth redirects, cookies across subdomains and host-based routing behave the same in both environments.
There is a clear recommendation for choosing the ending: .test is reserved by IANA and therefore never collides with real domains. You should avoid .local (conflicts with mDNS/Bonjour) and .dev (owned by Google and forces HTTPS via HSTS).
Pitfalls and trade-offs
No tool comes without catches. You should know these points beforehand:
- Administrator rights: Port 443 and the local CA require elevated privileges. On managed company laptops this can be a hurdle that has to be cleared with IT first.
- Pre-1.0: Portless is not yet at version 1.0. The format of its internal state folder can change between releases, in which case you may have to run
portless trustagain. A global installation avoids different versions across the team. - Node.js 24 or newer: If you are still stuck on an older Node version, you have to update first.
- Complex scripts: For compound commands (
&&, pipes,NODE_ENV=production vite) or redirects to other scripts, Portless does not add port flags. There you set the port yourself. - Process boundaries: A reverse proxy is an additional layer. If something goes wrong, there is one more place to look.
None of this is a deal breaker. But it is worth trying the tool in one project first instead of rolling it out to the whole team right away.
Conclusion: names instead of numbers
Portless solves a problem many of us have carried around for so long that we barely notice it anymore. A stable name per app, HTTPS without tinkering, clean subdomains and worktree support that simply works: that noticeably reduces the small frictions of everyday development.
The effort to try it is low. You install it globally, replace "dev": "next dev" with portless in one project and find out after the first start whether you like the new way of working. If you regularly run several projects in parallel, work with worktrees or agents, or want to test locally as close to production as possible, it is worth a try.
Your next step: Pick a project, install Portless and start it with a name of your choice. That's all it takes.
Want to tidy up your local development setup and team workflows, or need support with a web project? Find out more under web development, or write to me directly.