Documentation
localhost-proxy (lhp) gives every git worktree — and every app in a monorepo — a stable dev URL like http://feature-auth.my-repo.test, behind a tiny local reverse proxy you never think about.
Install#
$ bun add -g @smarchetti/localhost-proxy # or npm i -g / pnpm add -g
$ bun add -d @smarchetti/localhost-proxy # or per project, as a dev dependency
The package ships two equivalent binaries, lhp and localhost-proxy. The runtime is Node ≥ 20 (the daemon deliberately runs under Node — Bun's node:http currently drops writes to upgraded WebSocket sockets, which would break HMR passthrough).
Quick start#
1. Point macOS at the proxy — once
$ lhp setup
One sudo step writes /etc/resolver/test, telling macOS to send *.test lookups to lhp's built-in loopback DNS responder. Delete the file to undo. Prefer zero setup? lhp config domain localhost uses *.localhost URLs, which resolve natively in every browser — no sudo, nothing to install.
2. Wrap your dev command
// package.json
"dev": "lhp -- next dev"
// vite ignores PORT — use the {port} placeholder
"dev": "lhp -- vite --port {port}"
lhp allocates a free port, exports it as PORT, registers the route, and passes everything through. When the command exits — cleanly or not — the route is removed.
3. Run dev anywhere
$ pnpm dev
┌ localhost-proxy
│ worktree feature-auth.my-repo (feature/auth)
│ proxied http://feature-auth.my-repo.test
│ upstream http://localhost:52341
└ running next dev
The names come from git — branch, repo, and (in monorepos) the package — so there is nothing to configure and nothing collides. The dashboard at http://test (or http://localhost) shows everything running, grouped by repo, with health and request counters.
How it works#
lhp wraps; a tiny daemon routes. The first lhp run starts a reverse-proxy daemon on port 80 (unprivileged on modern macOS — that's why the URLs need no port suffix). It routes by hostname, forwards the original Host header plus X-Forwarded-Host/X-Forwarded-Proto, and pipes WebSocket upgrades straight through, so HMR just works.
Routes clean themselves up. Stop the dev server and its route unregisters; crash it and the daemon prunes the dead registration on its next probe. Routes persist to disk, so a daemon restart reloads them. Visiting a name that isn't running gets a page listing everything that is; a registered name whose server refuses connections gets a "still starting?" page instead of a raw error.
Loopback only. The listeners bind to loopback, the control API answers only to local hostnames, and POSTs must be application/json — which cross-origin forms can't send, closing the CSRF door. Your dev servers stay yours.
Name collisions get suffixes. If two different directories request the same name, the second becomes name-2. The banner and lhp list always show the real URL. Re-running from the same directory reuses its name.
URLs & naming#
A route name is composed from a scheme — a dot-list of label tokens, joined left-to-right into the hostname. The default is branch.app.repo:
branchThe checked-out branch, sanitized to DNS labels (feature/auth → feature-auth). A detached HEAD uses the short commit SHA.appMonorepos only: the nearest package.json walking up from where lhp runs, scope stripped (@acme/web → web). A package.json at the worktree root doesn't count, so single-package repos skip this label.worktreeThe worktree directory's basename.repoThe main repository's directory name (shared by all its worktrees via the common .git dir), falling back to the origin remote's name.Tokens that resolve to nothing are skipped, and adjacent duplicate labels collapse (under worktree.repo, a checkout at ~/dev/my-repo is my-repo.test, not my-repo.my-repo.test). What you get by default:
main.my-repo.testSingle-package repo, main checkoutfeature-auth.my-repo.testWorktree on feature/authmain.web.my-repo.testThe web package of a monorepofeature-auth.web.my-repo.testSame package, in a worktreeChange the shape with lhp config scheme <tokens>:
$ lhp config scheme worktree.repo # the pre-0.2 default: auth.my-repo.test
$ lhp config scheme worktree # flat: auth.test
Repo scoping means worktrees of different repos never collide, and the dashboard groups by repo. Per-run, --name <name> overrides the whole composed name.
Monorepos#
Each app wraps its own dev script, and your task runner — turbo, nx, pnpm -r — stays the orchestrator. Run the whole stack or one app with the filters you already use; lhp needs no flags because runners set each package's working directory, which is where the app label comes from.
// apps/web/package.json — falls back cleanly where lhp isn't installed
"dev": "if command -v lhp >/dev/null 2>&1; then exec lhp -- next dev; else exec next dev --port 3000; fi"
$ turbo run dev # whole stack:
http://main.web.my-repo.test
http://main.api.my-repo.test
http://main.docs.my-repo.test
$ turbo run dev --filter=@acme/web # just one app
- Every app gets its own route — the
applabel keeps sibling packages from colliding, each on an auto-allocated port. Drop hardcoded--portflags from the lhp branch of the script (keep them in the fallback). - Apps can reference each other by URL. Names are stable, so
http://main.api.my-repo.testbeatslocalhost:<whatever>— cross-app links survive worktree switches and restarts. - The label is overridable — set
"app"in project config if a package's name isn't what you want in the URL.
Env & project config#
Apps that build absolute URLs (auth flows, OAuth callbacks, share links) must build them on the proxy origin, not localhost:<port> — otherwise cookies and CSRF break the moment a form posts upstream. Forwarded headers aren't enough in dev (the Next dev server rewrites them), so lhp injects environment variables:
- Always:
PORT,LHP_URL,LHP_NAME. - Built-in defaults (only when not already set in your shell):
AUTH_URL,NEXTAUTH_URL, andAUTH_TRUST_HOST=truefor Auth.js/NextAuth — the most common case, harmless for apps that don't use them. - Project config for everything else — an
"lhp"key in apackage.json, or a.lhp.jsonnext to it:
// package.json
{
"lhp": {
"app": "web", // optional: override the app label in the URL
"env": {
"VITE_PUBLIC_URL": "{url}",
"APP_HOST": "{url}",
"AUTH_URL": null // opt out of a built-in default
}
}
}
Config is hierarchical. Every package.json "lhp" key and .lhp.json from the worktree root down to the directory lhp runs in is merged, closer-to-the-app winning per key (within one directory, .lhp.json beats package.json). In a monorepo: stack-wide env in a root .lhp.json, per-app overrides in each app's package.json.
Precedence: shell env > project config > built-in defaults — an explicitly exported variable is never overridden. Shell env also beats .env files in Next, so injected values win over a stale NEXTAUTH_URL=http://localhost:3000 in .env.local. The {url}, {port}, and {name} placeholders work in env values and in the wrapped command itself.
HTTPS#
$ lhp config https on
$ lhp setup # sudo: adds the local CA to the system trust store
Worktree URLs become https://… (port 443, no suffix; http on 80 keeps working alongside), and injected URLs (LHP_URL, AUTH_URL, …) switch to https with X-Forwarded-Proto: https set.
Public CAs can't issue for .test, so the daemon runs its own CA in ~/.lhp/ca — name-constrained to your configured domain and .localhost. Even if the key leaked, it cannot sign for real websites: such a cert fails validation with a permitted-subtree violation. The daemon maintains one multi-SAN leaf certificate covering every registered host plus a *.<repo>.<domain> wildcard per repo, re-minting automatically as routes appear — new worktrees and apps need no cert step.
Firefox keeps its own trust store: set security.enterprise_roots.enabled to true in about:config so it honors the system keychain.
OAuth callbacks#
OAuth providers must whitelist each redirect URI. With stable per-worktree URLs you have two options:
- Exact URIs — register each worktree's callback as you create it, e.g.
http://feature-auth.my-repo.test/api/auth/callback/okta. Works everywhere, http or https. - A wildcard — turn on HTTPS and register e.g.
https://*.my-repo.test/api/auth/callback/oktaonce. Okta wildcard-matches https URIs only.
Know your provider's wildcard rules. Okta allows one * per URI, at the third domain level or deeper, matching a single label. That means https://*.my-repo.test covers feature-auth.my-repo.test but not a monorepo's feature-auth.web.my-repo.test (two labels under the repo). For monorepos, register a wildcard per app (https://*.web.my-repo.test/…) where the provider accepts the depth, fall back to exact URIs — or pick a flatter scheme for that trade-off.
Commands#
lhp [--] <cmd…>Wrap a dev command: allocate a port, register, run, unregister on exit. The -- is optional; lhp run -- <cmd…> is the unambiguous form for commands whose binary shares a subcommand name.lhp listRegistered worktrees and their URLs (ls works too)lhp statusDaemon pid, port, and route countlhp stopStop the daemon (it restarts on the next wrapped run)lhp configShow current + effective configurationlhp config <key> <value>Set port, domain, scheme, or https — see Configurationlhp setupThe sudo step: /etc/resolver entry for a non-localhost domain, plus CA trust when https is on. Run it again after changing domain or enabling https.lhp helpUsage summaryFlags (before the command)
--name <name>Override the composed route name for this run--port <port>Pin the upstream port instead of auto-allocating (an existing PORT env var is also respected)Configuration#
Settings live in ~/.lhp/config.json, managed by lhp config. Environment variables override the file; the file overrides defaults. After changing anything, run lhp stop — the daemon restarts with the new settings on your next dev run.
port · LHP_PROXY_PORTProxy listen port. Default 80 — port-free URLs, no root needed on macOS 10.14+. Anything else shows up as a :port suffix.domain · LHP_DOMAINURL domain. Default test (IETF-reserved, needs one lhp setup); localhost is zero-setup; anything made up works. .dev, .app (HSTS-preloaded — browsers force https) and .local (Bonjour) are refused with the reason.scheme · LHP_SCHEMEName shape: a dot-list of branch, app, worktree, repo. Default branch.app.repo.https · LHP_HTTPSon/off (default off). See HTTPS.LHP_HTTPS_PORTTLS listen port, default 443LHP_DNS_PORTLoopback DNS responder port, default 5354 (UDP; referenced by the /etc/resolver entry)For any non-localhost domain the daemon runs a tiny DNS responder answering *.<domain> → 127.0.0.1, and lhp setup writes /etc/resolver/<domain> pointing macOS at it. That resolver file is the single sudo-requiring piece; delete it to undo. *.localhost URLs always work as a fallback alongside a custom domain.
Files on disk#
~/.lhp/config.jsonSettings, managed by lhp config~/.lhp/routes.jsonRegistered routes, persisted so a daemon restart keeps them~/.lhp/daemon.logDaemon log — registrations, prunes, errors~/.lhp/ca · ~/.lhp/certsThe name-constrained local CA and the auto-minted leaf certificate (https mode)/etc/resolver/<domain>The one sudo-written file, from lhp setup. Delete to undo.Troubleshooting#
URLs don't resolve
For a non-localhost domain, lhp setup must have written /etc/resolver/<domain>, and the daemon must be running (it hosts the DNS responder). Check lhp status, then scutil --dns | grep -A2 <domain>. The *.localhost form of any URL always works meanwhile.
I changed config and nothing happened
The daemon keeps its startup settings. lhp stop — it restarts with the new ones on the next wrapped run. A domain change or enabling https also needs lhp setup again; a scheme change doesn't.
The proxy won't start on port 80
Something else owns it — often another web server, or a second lhp daemon (check ~/.lhp/daemon.log). Either stop the other process or move lhp: lhp config port 7777 (URLs gain :7777).
502 / "still starting" page
The route is registered but the dev server isn't accepting connections yet — normal during startup. If it persists, the wrapped server probably ignored PORT: pass the port explicitly with the {port} placeholder (e.g. lhp -- vite --port {port}) and remove hardcoded --port flags.
Auth redirects to localhost:<port>
The app is building absolute URLs from its own listener instead of the proxy origin. Auth.js/NextAuth is covered automatically; for anything else, map the env var your framework reads in project config — and check for a stale hardcoded URL in .env.local being read by something other than Next.
Browser distrusts the https cert
Run lhp setup after enabling https (it adds the CA to the system keychain). Firefox additionally needs security.enterprise_roots.enabled — it ignores the system trust store by default.
Two routes with a -2 suffix
Two different directories asked for the same name — usually two repos whose worktree dirs share a basename under a scheme without repo, or two checkouts on the same branch. Include more tokens in the scheme, or pin one side with --name.