A macOS menu bar app that starts your local development servers
and serves them at stable, trusted HTTPS domains.
Instead of remembering which project owns which port, you open
https://wishfox.localhost. Localfox detects the framework, the package manager and the
dev command, starts the server, finds the port it actually bound, and points a bundled
Caddy reverse proxy at it. When the port changes, the domain does not.
brew install --cask xergic/tap/localfoxOr download the DMG from Releases. Every release is signed with a Developer ID and notarized by Apple, so it opens without a Gatekeeper warning.
Localfox needs macOS 15 or newer on Apple Silicon.
On first launch, Localfox asks you to approve its helper in System Settings and to trust its certificate authority. macOS may ask for your password for each. See HTTPS for what is trusted and where.
Remove it with brew uninstall --cask localfox. Add --zap to also remove its helper, delete its
data and remove its certificate authority from your login keychain. Without --zap, the
helper stays loaded until you restart your Mac.
- macOS 15 or newer, Apple Silicon
- Xcode 26 or newer
brew install xcodegen swiftlint
Caddy is fetched and checksum-verified into Vendor/ by the build. You do not install it.
make run # regenerate the project, build, relaunch the app
make test # swift test, the whole kit suite
make detect DIR=~/Projects/wishfox # run detection headless
make env # print the resolved login shell environment
make lint # SwiftLint, must stay clean
make gen # regenerate Localfox.xcodeproj from project.yml
make caddy # fetch and verify the pinned Caddy binary
make snapshot # render the UI surfaces to snapshots/
make archive # ad hoc signed Release app and DMG into dist/| Path | Holds |
|---|---|
Sources/LocalfoxKit/ |
The whole pipeline. No SwiftUI, no AppKit, no UI state. |
Sources/localfox-run/ |
CLI over the same pipeline. |
App/Localfox/ |
SwiftUI shell: AppState, views, theme. |
App/Localfox/Telemetry.swift |
Anonymous usage signals through TelemetryDeck. |
Helper/Sources/ |
The root LaunchDaemon. The security boundary. |
Tests/LocalfoxKitTests/ |
Tests for the kit only. |
Vendor/caddy/ |
Pinned upstream Caddy. Git-ignored. |
Tools/ |
fetch-caddy.sh, fetch-icons.py, archive.sh, make-dmg.sh. |
DirectoryContext → DetectionEngine → WorkspaceScanner → CommandBuilder
→ ProjectStore
↓
ShellEnvironment → ProcessSpawner → PortDiscovery
↓
CaddyConfigBuilder → HelperProtocol (XPC) → CaddySupervisor → Caddy
- DirectoryContext loads a project directory once. Every detector is a pure function over that value, so detection tests never touch the disk.
- DetectionEngine scores every detector and takes the highest, applying
supersedesover the full hit set. SvelteKit, Astro, Nuxt and Remix all depend on Vite and would otherwise all report as Vite. - ShellEnvironment probes your login shell interactively for its real PATH. A GUI app
inherits launchd's PATH and cannot find
pnpm,bun, or nvm-managednode. - ProcessSpawner uses
posix_spawnwithPOSIX_SPAWN_SETSID, so the whole dev server tree lands in one process group that can be stopped reliably. - PortDiscovery walks libproc for listening sockets in that process group, scores the candidates, and confirms the winner with an HTTP request before trusting it.
- HelperService runs as root, owns ports 80 and 443, and is the only thing that talks to Caddy.
Every stage takes and returns plain Sendable value types. localfox-run drives the
identical pipeline, so detection and runtime are developable without the GUI.
macOS already resolves any *.localhost name to 127.0.0.1 and ::1, so Localfox never
touches /etc/hosts or runs a DNS server.
.test and .local were both rejected. .local is reserved for multicast DNS and
fights Bonjour. .test has no resolver support, and more importantly Vite explicitly
allow-lists localhost and *.localhost in its host check, so any other suffix would be
refused by every Vite-based dev server with "Blocked request. This host is not allowed."
Localfox runs a local certificate authority through Caddy's internal PKI and trusts its root in your login keychain, once, after you explicitly ask it to. macOS asks for your password to confirm. The trust covers your user account only.
The CA is Localfox's own, named separately from Caddy's default, so it never collides with
a root left behind by
8000
a hand-run caddy trust. Caddy's automatic trust installation is
disabled: Localfox owns trust so that removing it actually works.
Trust is verified by evaluating a real issued certificate with SecTrust, not by checking
whether a file exists. Removal matches the stored SHA-256 fingerprint, so it cannot delete
a different CA that happens to share a name.
Node, Bun and Deno ignore the system trust store, so Localfox injects
NODE_EXTRA_CA_CERTS into every dev server it starts. A server component calling
fetch("https://api.wishfox.localhost") works without further setup.
Binding ports 80 and 443 requires root. Localfox registers a LaunchDaemon through
SMAppService, which you approve once in System Settings under Login Items & Extensions.
There is no unprivileged fallback. Serving on :8443 would break the one promise the
product makes, and people would hardcode the port into their configs. Until the helper is
approved, the app shows a setup wall.
The helper never receives a Caddy config. It receives typed { id, domain, port } values
and builds the config itself, so a non-loopback upstream is not expressible across the
boundary rather than merely rejected. Every connecting client is checked against a code
signing requirement pinned to the bundle ID and Team ID.
A port route gives a domain to something Localfox did not start. A Docker container, a database admin page or an SSH forward all work. Use Route a Port from the + button in the dashboard, or add one when you edit a project.
A route has a domain and a fixed port, and no command. Start begins watching the port. Stop stops watching it and removes the domain. Localfox never signals the process that holds the port, because it did not start it.
While the port accepts connections, the route shows as running and the domain serves it. While it does not, the route shows as waiting and the domain is not served. Localfox checks every two seconds.
The check dials 127.0.0.1, because the proxy does. A server that listens on ::1 only
will show as waiting. Bind it to 127.0.0.1 too.
Quitting Localfox clears every route from the proxy, even though the servers behind them keep running. A route cannot be shared publicly yet.
The configuration becomes a newer format once it holds a port route, or a service with no folder. Localfox 1.2.0 cannot read that file and may overwrite it, so do not open it with 1.2.0. Builds from this version on leave an unreadable file alone.
A coding agent often starts pnpm dev in your project before you press Start. Localfox
looks for a server of yours that runs in the service's folder and that it did not start.
If it finds one, it asks before it starts a second copy.
Route It serves the service's domain from that server. Nothing starts and nothing is saved. Stop removes the domain and never signals the server. When the port stops answering, the service returns to stopped. Start Another Copy starts the service as usual.
Localfox matches the folder of the server, or of the pnpm or npm that launched it. It
ignores ports 80, 443 and above 49151. With several matches it takes the lowest port, or a
higher one if only that one answers HTTP. It checks only your own processes.
Coding agents such as Claude Code and Codex often work in a git worktree, for example
.claude/worktrees/fix-cart inside your project. Localfox finds a project's worktrees
by itself and lists each one under the project, labelled with its branch. Nothing is
added to your configuration, and a worktree you remove disappears from the list.
A worktree runs the project's own services, with the same commands, in the worktree's folders. A service whose folder does not exist in the worktree is left out, and so are port routes. Each one gets its own domain next to the project's, never under it:
| Project | Worktree on fix/cart |
|---|---|
wishfox.localhost |
wishfox-fix-cart.localhost |
api.wishfox.localhost |
api.wishfox-fix-cart.localhost |
A cookie set for wishfox.localhost does not reach a worktree this way, and a branch
called api cannot take the project's own API domain. A worktree whose domains are
already taken gets -2, -3 and so on.
Worktree services always pick their port automatically, because a fixed port would clash with the main checkout. Environment overrides set on a service are copied, and any value that names one of the project's domains is pointed at the worktree's domain instead.
Start, Stop, Open and Copy URL work as on any service, and so does a quick share. To change a worktree's services, edit the project. A worktree is closed to one line until you open it or something in it runs. Localfox looks for worktrees when the menu or the Localfox window opens. A server that is running when its worktree is removed stays on screen until you stop it.
Localfox can record what the proxy handled. Turn on Record requests in Preferences and each service's detail pane lists its recent traffic: method, path, status, duration and size. Caddy writes it, one JSON line per request, next to its own log.
The file holds the request line and the response, and nothing else. Request and response
headers are deleted before the line is written, so no cookie, Authorization value or
custom X-API-Key reaches disk. Caddy's own redaction covers only four header names, which
is not enough to make that promise, so Localfox drops both header maps outright.
The URL is recorded as sent, query string included. Turning the setting off removes the logger from the proxy config entirely rather than leaving it running and ignored.
A running service can be shared on the public internet from the Share button. There
are three ways to do it. localfox-run tunnel 5173 does the first one headless.
Quick tunnel. The default, and the only one that needs no setup. Localfox opens a
Cloudflare quick tunnel with the bundled cloudflared and hands you a random
https://….trycloudflare.com address. No Cloudflare account is involved. The address
changes every time and is not reserved to you.
Named tunnel. If you have a Cloudflare account with a domain, create a tunnel in Zero
Trust, point its public hostname at http://localhost:<port>, and paste the tunnel token
into the project's edit sheet. You get a stable address that survives a restart, and none
of the quick tunnel's limits. The token is stored in your Keychain and passed to
cloudflared through the environment, never on the command line.
A named tunnel is managed by Cloudflare, so the origin lives in your dashboard rather than in Localfox. That means the service needs a fixed port, and Localfox refuses a named share on a service set to Auto rather than publishing whatever last held the port.
SSH tunnel. If you have a VPS, Localfox can run ssh -N -R to it and let the share
answer there. Nothing goes through a third party. The remote sshd needs GatewayPorts yes, or the forward only listens on the server's own loopback, and Localfox says so
rather than reporting a tunnel that appears to work. The forward is plain HTTP unless you
terminate TLS on that box yourself, which is what the Public address field is for.
Localfox runs ssh in batch mode, so it will not accept an unknown host key on your
behalf. Connect to the host once from Terminal first.
Sharing is the one thing Localfox does that reaches past the loopback interface, so it is
worth being plain about what it means. Anyone with the link reaches your dev server
directly, with no authentication in front of it: your source maps, your .env values and
every API route. Sharing stops when the service stops and when Localfox quits, and is
never restored on relaunch, whichever mode you used.
A Cloudflare tunnel dials 127.0.0.1:<port> directly rather than going through the proxy,
so it works before the helper is installed and does not depend on the certificate being
trusted. For a quick tunnel Localfox rewrites the origin Host header to
localhost:<port> by default, which is what stops Vite and Next rejecting the request as
an unknown host. Turn that off in Preferences if your app builds absolute URLs from the
header, such as Django's ALLOWED_HOSTS or an OAuth callback. A named tunnel takes that
setting from your Cloudflare dashboard instead.
Localfox uses TelemetryDeck for anonymous usage counts. It is on by default and sends:
- A session start, with the app and macOS version.
- That one of these happened: a project was added or removed, a service was started, the
certificate was trusted or untrusted, the helper was installed, or a share was opened.
Shares and installs carry only a coarse kind or outcome, such as
quickorsuccess.
It never sends domains, hostnames, ports, paths, project names, URLs, tunnel addresses, certificate fingerprints or error messages.
Turn it off with Share anonymous usage data in Preferences. It takes effect at once and nothing more is sent.
Only the official release builds report. The TelemetryDeck app ID is injected by the release workflow, so a build from source or a fork sends nothing.
- Apple Silicon only.
- Firefox is not covered automatically. It can import roots from the macOS keychain, but that depends on its settings and on policy.
- A dev server that pins
server.hmr.portin its Vite config bypasses the proxy and its HMR socket will not connect. - A dev server bound to
0.0.0.0is already reachable from your LAN regardless of what Localfox does. Localfox flags this rather than claiming otherwise. - A process that daemonizes and escapes its process group is still stopped, because every
server carries
LOCALFOX_IDand Stop sweeps for it. One that clears its environment, or runs an Apple platform binary whose environment macOS withholds, cannot be found that way. - Cloudflare quick tunnels do not support Server-Sent Events, and cap at 200 concurrent in-flight requests. WebSocket HMR works over a quick share; an SSE-based reload does not. A named tunnel lifts both, and needs a Cloudflare account with a domain.
- A quick tunnel's address is assigned by Cloudflare, so Localfox cannot reserve, rename or password-protect it. Use a named tunnel for an address you control.
- A named tunnel needs a service with a fixed port, because Cloudflare owns the origin.
- A shared address is public for as long as the share is open, in every mode.
- A worktree uses the
.envfiles in its own folder as they are. Localfox rewrites the project's domains only in the overrides it stores, so a URL written in a.envfile still points at the main checkout's domain. - A worktree service picks its port automatically. A server that does not move to a free
port by itself, such as Django's
runserver, fails while the main checkout holds its default port. - Only Next.js and Vite have verified HMR behaviour behind the proxy. Other frameworks are detected but not yet proven end to end.
MIT.
Framework marks come from simple-icons,
CC0. Caddy is bundled under the Apache License 2.0; see Vendor/caddy/LICENSE.