FAQ
The questions a sceptical developer asks before typing a password into something that edits /etc/hosts as root. Answered from the code, including where the answer is uncomfortable.
Why not just edit /etc/hosts myself?
You can, and it gets you half way. /etc/hosts maps a name to an IP address — it has no concept of a port. So a line reading 127.0.0.1 myapp.test sends http://myapp.test to port 80 of 127.0.0.1, and your dev server is on 3000, where nothing is listening.
The missing half is something answering on port 80 *for that name*. Ports below 1024 need root, so that listener has to be started as root — and one name per port means one loopback address per name, because a name resolves to an address and the port is not part of the answer.
/etc/hosts 127.0.0.2 myapp.test (a line inside a marked block) lo0 alias 127.0.0.2 (added as root, cleared by a reboot) forwarder 127.0.0.2:80 ──bytes──▶ 127.0.0.1:3000
That is the whole product: it does those three things behind one admin prompt, keeps them consistent as you add, rename and delete aliases, and takes all three back out when you uninstall. Your dev server is not touched, not restarted, and never learns any of it happened.
What exactly runs as root, and for how long?
One process. It is called the agent, and the agent is the TCP forwarder — one root process, not two.
- It starts behind one macOS admin prompt at app launch. The prompt runs
osascript … with administrator privilegeson a single script,Contents/Resources/privileged/apply.sh, which ships inside the bundle so you can read it before you type your password. - Nothing is installed. No
LaunchDaemonin/Library, noSMAppServicedaemon, nosudoersentry, nosudoinstaller script. There is no root component left on the machine when the app is not running. - It lives exactly as long as the app. The app touches
~/.config/localhost-aliases/livenessevery 5 seconds and the agent exits by itself once that file has been stale for 15 seconds — a user process cannot kill root, so root owns its own lifetime. Quitting the app is clean and asks for nothing. - While it is up there are no further prompts: it watches
desired-state.jsonand reconciles the machine to it —lo0addresses, the managed/etc/hostsblock, a DNS flush, its own routes.
The tradeoff, stated plainly
desired-state.json is writable by your user and a root process acts on it, so any process running as you can ask root to add a loopback address and edit our block in /etc/hosts. That is a real local privilege escalation and we would rather you read it here than discover it. It is bounded by the agent never trusting that file: every hostname is re-validated on every read, addresses are confined to the 127.0.0.2–127.0.0.254 pool, an address the agent did not allocate is never removed, forward targets must be loopback, and the /etc/hosts write is refused outright if a single byte outside the markers would change.Can I get https, with a real padlock?
Yes. Turn it on in Settings and every alias also answers on https://; http:// keeps working alongside it, so nothing you have bookmarked breaks. This works because each alias owns its own loopback address — a listener on 127.0.0.3:443 already knows which alias it is, so it can present that certificate without reading a single byte of your traffic. No Host header is parsed, no SNI is inspected. After the handshake it is the same raw splice as ever, which is why WebSockets and HMR are unaffected.
The certificate is issued for you and renews itself. One step is deliberately not automatic: your Mac has to be told to trust the local authority that signs it, and macOS asks for your keychain password when you do. An app that quietly installs a trusted root would be indistinguishable from malware, so Settings hands you the command instead. Firefox keeps its own list of trusted authorities and will still warn until you add it there too.
This is why some TLDs are refused
.dev, .app, .page and the rest of the HSTS preload list are rejected by validation: the browser rewrites http:// to https:// for those names before the request leaves your machine, so you would get a TLS error rather than your app.Why .test and not .local?
.test is reserved for development by RFC 6761: never delegated, never publicly resolvable, and claimed by nothing on macOS — so the /etc/hosts line is simply the answer, immediately.
.local is reserved for multicast DNS by RFC 6762, and on macOS mDNSResponder owns that suffix. Every lookup goes onto the network as a multicast query and the resolver waits that query out first — roughly five seconds per name, measured on a developer Mac by timing getaddrinfo directly:
| name | in /etc/hosts? | getaddrinfo |
|---|---|---|
index.local | yes | 5.008s |
nope-xyz.local | no | 5.006s |
nope-xyz.test | no | 0.003s |
broadcasthost | yes | 0.003s |
The second row is the finding: a .local name that is in no hosts file at all costs the same five seconds as one that is, so the delay cannot be caused by anything we wrote there. The suffix is the cost. .localhost is refused for a different reason — macOS answers that suffix itself as 127.0.0.1 without reading /etc/hosts, so the name would never reach the alias's own 127.0.0.x. The dashboard offers test, internal, lan, home.arpa and example; the numbers and the commands that produce them are on how aliases work.
Does it work with Vite, Next and Rails — HMR, WebSockets, the lot?
Yes, and for a boring reason: nothing is parsed, so there is nothing to be incompatible with. The forwarder accepts on 127.0.0.2:80, connects to 127.0.0.1:3000 and copies bytes in both directions until one side closes. A WebSocket upgrade, an HMR socket, server-sent events, a long-lived streaming response, or a protocol that is not HTTP at all — all of it passes through untouched, and your dev server sees an ordinary client.
- Your dev server has to be listening on
127.0.0.1or0.0.0.0. One bound only to IPv6::1is not reachable —lsof -nP -iTCP:3000 -sTCP:LISTENshows which address it actually took. - The
Hostheader arrives as you typed it,myapp.test. Frameworks with a host allow-list — Rails'config.hosts, Vite'sserver.allowedHosts— will refuse a name they have not been told about, so add it there once. - The origin changes, and anything pinned to
http://localhost:3000changes with it: a cookie set onlocalhostis not sent tomyapp.test, and OAuth callbacks or CORS allow-lists need the new origin. - Changing an alias's target port needs no admin prompt — the running agent reloads its routes and retargets itself.
What happens when I reboot?
The /etc/hosts block is a file, so it survives untouched. The lo0 addresses do not — macOS clears extra loopback addresses at boot — and the root agent went with the session that started it.
So the first launch after a reboot compares the live machine against your configuration and, when it has actually drifted, raises the one admin prompt again to put the addresses back and start the agent. If nothing drifted, it does not prompt. Nothing re-adds itself while the app is not running: that is the same property as "nothing is installed", seen from the other side.
Launch at login is available (SMAppService, macOS 13+) and the settings drawer says what it costs before you turn it on: with this model, launching at login means one admin prompt per login.
How do I uninstall, and what is left behind?
Quit the app, then run make uninstall — or use the tray's Uninstall… item, or the dashboard's settings drawer. All three run the same script, and the copy that runs is the one inside the installed bundle, so removing the app never needs a checkout of its source.
- 1One admin prompt. As root: stop the agent, remove the
lo0addresses it added, strip the managed block from/etc/hostsleaving every other line byte for byte, flush DNS, and hand back every file root created inside your directories. - 2Then, with no privileges: remove the local CA from your login keychain — matched by SHA-1 fingerprint, never by name, because deleting the wrong certificate is unrecoverable — then
~/.config/localhost-aliases, the logs, and finally the.appitself.
What is left behind is nothing of ours. Your projects are untouched, any .localhost-aliases.json you asked it to write stays in the repository it belongs to, and /etc/hosts keeps every line outside the markers. Cancelling the password prompt removes nothing at all — the run stops there rather than half-dismantling the machine. If a step fails it says so and carries on to the end, so you never end up with an app that has removed its own system state but not itself.
And if the app is gone, broken, or you simply stop trusting it: both changes are visible and reversible by hand — delete everything between the # >>> localhost-aliases >>> markers in /etc/hosts, and sudo ifconfig lo0 -alias 127.0.0.2 for each address. Troubleshooting spells it out.
Does it phone home? Does it need a network connection?
No, and no. There is no telemetry, no analytics, no crash reporting, no account and no licence check — the app makes no outbound connection of its own. Every address it creates is loopback, so nothing it sets up is reachable from your network or from the internet.
It does not need the network to work either: resolution is a line in /etc/hosts, the forwarder connects to 127.0.0.1, and the traffic never leaves the machine. The only thing that needs an internet connection is you, cloning or downloading it in the first place.
There is no auto-update, and nothing checks for one. A new build is a download you choose to make; the changelog is how you find out that one exists.
Is it signed and notarized?
No — and there is no published build at all
That is less alarming than it sounds for a source build, and the reason is worth knowing: Gatekeeper acts on the quarantine attribute that a browser attaches to a downloaded file. make install copies the bundle you compiled a minute ago and clears that attribute, so there is no unidentified-developer dialog — the thing you are trusting is your own compiler, not our signature.
The release workflow can sign and notarize once the Apple credentials are configured, but no build has been through it. If a .dmg ever appears on the download page, do not take this page's word for its signature — check the file you actually have:
what the file itself says
spctl -a -vvv -t install /Volumes/Localhost\ Aliases/LocalhostAliases.app # notarized: accepted / source=Notarized Developer ID # otherwise: rejected xcrun stapler validate /Volumes/Localhost\ Aliases/LocalhostAliases.app # proves the ticket is stapled, so the check also holds offline
Apple Silicon or Intel?
Apple Silicon only. The tray is compiled with -target arm64-apple-macos13.0 and there is no universal binary, so there is no x86_64 slice for an Intel Mac to run. Rosetta does not help: it translates Intel code so it can run on Apple Silicon, not the other way round.
The floor is macOS 13 Ventura, which is LSMinimumSystemVersion in the bundle's Info.plist and the same version as the Swift deployment target. The rest of the app is Bun and shell, and would port; the menu-bar app and the privileged work — ifconfig, /etc/hosts, dscacheutil — are macOS-shaped and are not intended to.
Still unanswered? The docs go further, and troubleshooting covers what to check when a name does not resolve.