How aliases work
One loopback IP per alias, one line in /etc/hosts, and a raw TCP forwarder in between.
The mechanism
DNS maps a name to an IP address and never to a port. That is the whole problem: myapp.test can be made to resolve, but it will resolve to :80, not to your dev server's :3000. So each alias gets its own loopback address, and a small root process carries port 80 on that address to the port your dev server already listens on.
/etc/hosts 127.0.0.2 myapp.test lo0 alias 127.0.0.2 forwarder 127.0.0.2:80 ──raw bytes──▶ 127.0.0.1:3000
Your dev server is not touched, restarted or reconfigured. It keeps listening on 127.0.0.1:3000 exactly as before; the alias is an addition, not a replacement, and http://localhost:3000 keeps working.
Raw bytes, not HTTP
The forwarder splices sockets. It parses nothing — no Host header, no header rewriting, no hop-by-hop rules. That is the KISS choice, and it has consequences worth knowing:
- WebSockets and HMR work for free. Passthrough does not care what the bytes are.
- Any protocol works, not just HTTP.
- There is no proxy in the request path to misinterpret anything, because there is no proxy.
https:// is optional, and off by default
http://. Turn https on in Settings and they answer on https:// too, without losing http://. Terminating TLS is possible despite the raw splice because each alias owns its own loopback address: a listener on 127.0.0.3:443 identifies the alias from the address, so it presents that certificate without parsing a Host header or inspecting SNI. The certificate is issued and renewed for you; telling your Mac to trust the authority that signed it is the one manual step, because macOS asks for your keychain password. Firefox uses its own certificate store and needs that step separately.Loopback addresses
Addresses are allocated from 127.0.0.2 to 127.0.0.254. 127.0.0.1 is never used — it is the real loopback, and your dev server is on it. The lowest free address is assigned when an alias is created and stays with that alias for life, so a name never silently moves.
253 aliases is the hard ceiling, being the size of the pool. The dashboard says so if you ever reach it.
The /etc/hosts block
All managed entries live between two markers. Everything outside them — including anything you added yourself — is preserved byte for byte, and the file is written atomically.
/etc/hosts
# >>> localhost-aliases >>> 127.0.0.2 index.test 127.0.0.3 myapp.test 127.0.0.4 api.myapp.test # <<< localhost-aliases <<<
Those markers are the uninstall contract. If you ever need to recover by hand, deleting the block between them removes every alias — see troubleshooting.
index.test, the reserved alias
index.test is always present and maps to the dashboard itself, so Open Dashboard opens a name rather than a port number. It cannot be renamed or deleted from the UI. The dashboard is embedded in the app bundle and listens on 127.0.0.1:7788 by default; it only answers while the app is running.
The TLD: .test
Every alias ends in .test. RFC 6761 reserves that suffix for development: it is never delegated to a registry, never publicly resolvable, and nothing on macOS claims it — so the line in /etc/hosts is the answer, immediately. Changing the TLD renames every hostname, so the next apply needs one admin prompt.
.local is not supported
.local is reserved for multicast DNS by RFC 6762, and on macOS mDNSResponder owns it: a lookup is put to the network as a multicast query and the resolver waits that query out. On the machine this was measured on, that wait is about five seconds per name — the same five seconds whether or not the name is in /etc/hosts, which is what proves the suffix is the cost rather than anything the app writes. So .local is rejected rather than offered. It is not broken: Bonjour resolves the names it actually owns quickly, which is what it is for. It is the wrong carrier for a name whose answer is a static line in a file.Reproduce it yourself: time getaddrinfo for a .local name that exists nowhere, and for a .test name that exists nowhere. The first waits, the second returns in microseconds.
time getaddrinfo
bun -e '
const dns = require("node:dns");
for (const n of ["nope-xyz.local", "nope-xyz.test"]) {
const t = Bun.nanoseconds();
await new Promise((r) => dns.lookup(n, () => r()));
console.log(n.padEnd(16), ((Bun.nanoseconds() - t) / 1e9).toFixed(3) + "s");
}'nope-xyz.local 5.006s nope-xyz.test 0.003s
HSTS-preloaded TLDs are rejected too
.dev, .app, .page and the other TLDs on the browsers' HSTS preload list are upgraded from http:// to https:// by Chrome and Safari before a request ever leaves the machine. Project aliases are http:// only — the forwarder never parses the traffic, so nothing can present a certificate — which means an alias under one of those TLDs would fail with a TLS error that says nothing about the real cause. Validation refuses them with that reason instead. The last label is what decides, so foo.dev is refused exactly as dev is..localhost is refused for a third reason: macOS resolves every name under it to 127.0.0.1 on its own and never reads /etc/hosts, so the name would land past the forwarder on a port nothing is listening on.
Settings offers test, internal, lan, home.arpa and example as quick picks, and you can type another. A refused suffix is refused inline, with the specific reason it fails rather than a flat “not allowed”.
Where state lives
| Path | What it is |
|---|---|
~/.config/localhost-aliases/config.json | your aliases, TLD and dashboard port |
~/.config/localhost-aliases/desired-state.json | what the privileged script is asked to make true |
~/.config/localhost-aliases/routes.json | what the forwarder watches; a port edit rewrites this and nothing else |
~/.config/localhost-aliases/forwarder-status.json | what the root forwarder reports, readable without privileges |
~/.config/localhost-aliases/liveness | the heartbeat that keeps the root forwarder alive |
~/Library/Logs/localhost-aliases/ | logs, including every privileged run |
/etc/hosts | the managed block, between the markers above |