Here is what my browser history looked like on a normal Friday: localhost:5173, localhost:9820, localhost:9830, localhost:9835, localhost:9840, and somewhere in there a 127.0.0.1:1420 I could never remember the purpose of. Every project I run gets its own block of ports. My portfolio site alone is a 98xx block: API, web, admin, a database dashboard. I had the numbers memorised the way you memorise a phone number you never wanted to know.
That is a small annoyance and I would have lived with it forever. What actually made me stop and build something was HTTPS.
The moment port numbers stop being harmless
A lot of the web platform is gated on a secure context. Secure cookies. Service workers. OAuth redirects that refuse anything but https://. SameSite=None. The Clipboard API. The moment a local app needs any of those, http://localhost:5173 stops being enough, and the usual answer is a self-signed certificate that the browser warns you about every single time, on every single hostname, in every single browser profile.
The specific case was The Remote & Ledger, a job-search tool I run on my own machine as a background service. I wanted it to live at a real address, always on, with the clipboard and cookie behaviour of a real site. A hosts entry gets you a name, but a hosts entry maps a name to an address, not a port — so the URL was http://remoteledger.local:5173, which is a name with the port still bolted on and no HTTPS. Not the thing I wanted.
If you use OrbStack you already know the feeling of the good version of this. Every container gets something.orb.local, over HTTPS, no port, no warning, and it just works. I wanted exactly that, but for whatever I was already running on the host: a Vite dev server, a NestJS API, a Next.js app, a Python thing. Not containers. Processes.
What I built
dropport is a small command-line tool. You tell it a name and a port, and it puts a reverse proxy in front of that port on 80 and 443, with a certificate your machine trusts, and keeps it there across reboots.
npm install -g dropport
dropport add myapp 5173 # -> https://myapp.dp.local
dropport up # install and start the proxy
dropport trust # trust the local CA, so https is clean
open https://myapp.dp.localNo :5173. No certificate warning. Adding another app while the proxy is running reloads it in place. dropport list tells you what is registered, dropport status tells you whether it is working, and dropport doctor tells you why it is not.
The honest description is that dropport is a thin wrapper around Caddy, which does the hard parts. Caddy already runs a local certificate authority and already reloads configuration without dropping connections. I did not want to reimplement a proxy or a CA, and anybody who tries to should be gently stopped. What dropport owns is the boring, sharp-edged part around it: the registry of apps, the generated Caddyfile, the hosts file, and the privileged service that holds the ports.
That generated Caddyfile is deliberately dull:
{
local_certs
}
myapp.dp.local {
reverse_proxy 127.0.0.1:5173
}local_certs is the load-bearing line. Without it, Caddy tries to get a public certificate from Let's Encrypt for a name that can never be validated, and fails on every start. I learned that the way one learns most things about certificates: by staring at a log for longer than I would like to admit.
Root, three times, each time announced
Anything that binds port 80 needs privileges, and I have a rule about tools that ask for my password: they should say exactly what they are about to do, and do nothing else. dropport escalates for three things, and it prints each one before it runs.
Bind 80 and 443. A launchd daemon on macOS, a systemd unit on Linux. Something has to hold those ports across reboots, and that something cannot be a process in a terminal tab.
Point a hostname at 127.0.0.1. One tagged line per app in
/etc/hosts. Edits are staged to a temporary file and copied in, so a failure halfway through can never leave you with half a hosts file, and lines dropport did not write are never touched.Make HTTPS clean. Caddy's local CA goes into the system trust store, once.
Nothing else escalates. Publishing a name over mDNS, which I will get to, needs no privileges at all, so it runs as a user agent rather than as root.
The five-second mystery
The first version used .local names and every lookup took five seconds. Not sometimes. Every time, with unsettling precision: 5,009 milliseconds on macOS 15.
The reason is that .local is not an ordinary suffix. It is multicast DNS, RFC 6762, the namespace every printer, phone and Mac on your network answers into. A .local lookup goes out as a multicast question first, and only when nothing answers, after the resolver has waited out its full timeout, does it fall back to the hosts file. The tell is that a name which does not exist at all takes exactly as long as one that does.
So this is how OrbStack's *.orb.local feels instant: not a faster lookup, but a responder that replies. dropport now does the same thing. It registers its names with the responder the system already runs, Bonjour on macOS and Avahi on Linux, rather than speaking mDNS itself, and supervises the registration so it outlives the command that created it. The number went from 5,009 ms to somewhere between 3 and 9.
That investigation also produced a rule. dropport refuses bare .local names. Claiming printer.local means competing with a real printer, on some networks and not others, which is a miserable bug to hand someone. So a bare name is expanded under .dp.local, and --force exists for anyone who genuinely means it. If you would rather skip multicast entirely, .test works too: it is reserved for exactly this by RFC 6761, nothing competes for it, and no publisher is needed.
What broke: the doctor lied
Every project on my site has a section called what broke, because I trust an engineer who tells me what went wrong more than one who says nothing did. Here is dropport's.
An early fix stopped doctor from reporting dropport's own proxy as a port conflict. Sensible. Except the fix short-circuited: if the proxy was running, both 80 and 443 were assumed to be ours. That is false the moment something else holds port 80, which is the normal situation on a machine that also runs OrbStack or Docker Desktop. Adding an app would then regenerate the config without the line that leaves port 80 alone, Caddy would fail to bind, and doctor would cheerfully report everything healthy, because from the outside a daemon that cannot bind looks identical to one that is fine.
Ownership is now asked per port. Caddy's admin API reports which addresses the proxy is actually bound to, and anything bound outside that list belongs to someone else. doctor also stopped conflating installed with running, and a crash-looping proxy no longer claims a port it cannot bind. Three small commits, each of them the kind of thing you only find by using the tool on a machine that is not pristine.
How it helped me
The ledger now lives at https://remoteledger.dp.local. It is simply there whenever the laptop is on, with real cookies, a working clipboard and no warning page. The dev stack for this site went the same way. I stopped remembering ports the week I published it, which is a strange feeling after years of knowing them by heart.
It also did something I did not plan for. Because the ledger notices when dropport fronts its port, arriving on the old :5173 address now redirects to the clean URL. The two tools know about each other just enough to be polite.
The whole thing was written and published on the same afternoon: a few files of plain JavaScript, about 1,200 lines, no runtime dependencies, MIT. It is on npm as dropport and the source is on GitHub. It wants Node 20 and Caddy on your PATH, it works on macOS and Linux, and on Windows it will generate the config but leave the service and the hosts file to you.
If you have more than two dev servers running at once, try it. If it breaks, run dropport doctor. It tells the truth now.
Comments
No comments yet.