hostmux is a small reverse proxy built around host-based routing: one local HTTPS listener fronts every app you run, so you stop juggling ad hoc ports and scattered reverse-proxy snippets. Point Cloudflare Tunnel (cloudflared) at that listener and you can share real hostnames (app.example.com, api.example.com) for local development, so teammates and other devices (e.g. phones for testing) hit the same URLs you do without checking in brittle port numbers or tunnel config per repo.
- Single static binary — self-hosted; one executable, no separate proxy stack to install
- Host-based routing — one HTTPS entrypoint; route many upstreams by the
Hostheader - Cloudflare Tunnel — works well with Cloudflare Tunnel (
cloudflared): point the tunnel at hostmux’s HTTPS listener and serve local development through Cloudflare’s edge and DNS - Ephemeral port per process — hostmux picks a free TCP port, injects
PORTinto your dev command, registers hostnames, streams stdio, and tears down routes on exit (including crash orkill -9) - Git worktrees — auto-prefix hostnames so parallel branches of the same repo don’t collide
- Persistent routes — optional TOML with hot reload when you prefer config-as-code
On macOS and Linux, install with Homebrew from the Limetric tap:
brew install Limetric/tap/hostmux
hostmux versionDownload the latest binary from GitHub Releases.
Release assets are named consistently as hostmux-<os>-<arch> (with .exe on
Windows): linux/darwin/windows × amd64/arm64.
chmod +x hostmux-linux-amd64
sudo mv hostmux-linux-amd64 /usr/local/bin/hostmuxgit clone https://github.com/Limetric/hostmux.git
cd hostmux
go build -o build/hostmux .The examples below assume hostmux is on your PATH.
hostmux stop # stop the daemon
hostmux service uninstall # if you installed the service
hostmux untrust # if you trusted the dev certificate
rm -f /usr/local/bin/hostmux # or wherever you installed it
rm -rf ~/.hostmux # managed TLS material, socket, discovery
rm -rf ~/.config/hostmux # config (if you created one)# Start the daemon with TLS enabled by default on :8443 (hostmux start --foreground to stay attached).
hostmux start
# Run a dev server and register a subdomain for it (starts the daemon in the background if it is not already running).
hostmux run --domain example.com --name myapp -- bun run dev
# Inspect the active routes (add --wide for age/pid/labels/command, --json for scripts).
hostmux routes
# Print the URL for a route without starting anything.
hostmux url --domain example.com --name myapp
# Diagnose setup/runtime problems (config, socket, daemon, TLS).
hostmux doctor
# Validate the config file without starting the daemon.
hostmux config check
# Tail the background daemon's log (access/proxy logs, startup errors).
hostmux logs # print ~/.hostmux/hostmux.log
hostmux logs -f # follow new lines
hostmux logs -n 100 # last 100 lines
# Stop the daemon.
hostmux stopTired of typing --domain on every line? Put domain = "example.com" in ~/.config/hostmux/hostmux.toml, restart the daemon, and bare --name / hostmux url picks that up. Same hostnames, less copy-paste.
# Multiple subdomains for the same upstream.
hostmux run --domain example.com --name app --name admin -- bun run dev
# Omit --name to infer from the nearest ancestor package.json name.
hostmux run --domain example.com -- bun run dev
# Full hostnames skip domain expansion, but still use the normal prefix logic unless --no-prefix is set.
hostmux run --name myapp.example.org -- bun run dev
# Hold the URL until the dev server is actually accepting requests (avoids a transient 502).
hostmux run --wait --name app -- bun run dev
hostmux run --wait-url /healthz --wait-timeout 60s --name api -- go run ./cmd/api
# Print the final URL using the same domain/prefix logic as `run`.
hostmux url --domain example.com --name app
hostmux url --domain example.com --prefix feature-x --name app
hostmux url --domain example.com --name app --name adminhostmux run owns the child process, but some dev servers are managed
elsewhere (an IDE, Docker Compose, a Procfile, a watcher). Use expose to
route to an already-running upstream without handing hostmux the process
lifecycle:
hostmux expose --name api --upstream http://127.0.0.1:3000
hostmux expose --domain example.com --name admin --upstream http://127.0.0.1:9000
hostmux unexpose apiExposed routes persist until you unexpose them (or the daemon restarts),
appear in hostmux routes under a manual:NAME source, and accept the same
--domain / --label options as run. Add --persist to also append the
route to your config file (validated and written atomically) so it is restored
on the next daemon start:
hostmux expose --name api --upstream http://127.0.0.1:3000 --persistThe first --name is the route's
identifier for unexpose.
Print a route's public URL and details, with a scannable QR code for testing from a phone:
hostmux share api # URL, upstream, source, + QR when stdout is a TTY
hostmux share --qr --name api
hostmux share --all # every registered route
hostmux share api --json # structured output for toolingA QR code renders by default in an interactive terminal (--qr / --no-qr
force it). Unregistered names still print a generated URL, clearly marked as
not currently registered.
By default, hostmux start launches the daemon and it listens on :8443, generates a self-signed certificate if needed, and stores it at ~/.hostmux/tls/hostmux.crt and ~/.hostmux/tls/hostmux.key.
If that managed certificate expires or you want a fresh one, remove ~/.hostmux/tls/ and restart hostmux start.
Generate a matching ingress snippet for your config with hostmux cloudflare config
(reads the config file, and live daemon info when reachable):
hostmux cloudflare config # prints the ingress block below
hostmux cloudflare config --domain example.comIf you want HTTP/2 multiplexing on the tunnel-to-origin hop, point cloudflared at the default HTTPS listener:
ingress:
- hostname: "*.example.com"
service: https://127.0.0.1:8443
originRequest:
http2Origin: true
noTLSVerify: trueBy default hostmux url and hostmux run print URLs that include the
real listener port — https://api.example.com:8443 — which is wrong when
cloudflared terminates the public connection on standard HTTPS and the
public URL has no visible port. Set hide_port = true in the daemon
config to drop the port from those printed URLs while the daemon keeps
listening on the unprivileged :8443:
listen = ":8443"
domain = "example.com"
hide_port = truehostmux url --no-prefix api then prints https://api.example.com.
Keep hostmux running across logins with a user-level service:
hostmux service install # macOS launchd agent or Linux systemd user unit
hostmux service status # installed? running?
hostmux service uninstallNo root is needed for the default user-level install. Windows has no native
service support yet — run hostmux start from a logon script or wrap
hostmux start --foreground with Task Scheduler / nssm.
Browsers warn on hostmux's self-signed certificate until it is trusted by the OS. Install it once:
hostmux trust # add the managed cert to the OS trust store
hostmux untrust # remove it againSupported on macOS (security), Linux (update-ca-*, may prompt for sudo),
and Windows (certutil -user Root). trust is idempotent — it exits 0 if the
cert is already trusted unless you pass --force. Restart open browser tabs
after trusting.
To trust automatically on daemon start, set auto_trust = true under [tls]
or export HOSTMUX_TLS_AUTO_TRUST=1. It is off by default to avoid surprising
elevation prompts.
Note: hostmux currently trusts the self-signed leaf certificate directly. A local-CA model (so renewals don't require re-trusting) is planned.
Inspect or rotate the managed certificate:
hostmux cert info # subject, SANs, validity, expiry (add --json)
hostmux cert path # print the cert/key paths
hostmux cert renew # regenerate the managed cert (restart the daemon after)cert renew refuses to overwrite a custom tls.cert unless you pass --force.
Browsers treat https://app.localhost as port 443 by default. Hostmux
ships with listen = ":8443" because binding :443 usually requires
extra privileges — but that means users have to type
https://app.localhost:8443, which hurts the *.localhost workflow.
If you want port-less URLs that match browser defaults, configure hostmux
to listen on :443 using one of the patterns below.
When you do this, hostmux url and hostmux run automatically print
URLs without the :443 suffix, matching the browser's address bar.
Stay on :8443 and they include :8443 so the URL is still clickable.
The lightest touch is the CAP_NET_BIND_SERVICE capability. Grant it to
the binary once; after that hostmux binds :443 unprivileged:
sudo setcap cap_net_bind_service=+ep /path/to/hostmuxIf you re-build or upgrade, re-run setcap — capabilities are attached
to inodes, not paths.
For systemd-managed installs, add the capability via unit config instead:
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICEAs a fallback, redirect 443 → 8443 with iptables or nft and keep
hostmux on :8443:
sudo iptables -t nat -A OUTPUT -p tcp -o lo --dport 443 -j REDIRECT --to-ports 8443macOS's packet filter can redirect 127.0.0.1:443 → 127.0.0.1:8443 so
hostmux itself stays unprivileged. Because browsers on modern macOS
usually resolve *.localhost to ::1 (IPv6 loopback), the anchor needs
both IPv4 and IPv6 rules. Create /etc/pf.anchors/hostmux with:
rdr pass on lo0 inet proto tcp from any to 127.0.0.1 port 443 -> 127.0.0.1 port 8443
rdr pass on lo0 inet6 proto tcp from any to ::1 port 443 -> ::1 port 8443
Add an anchor hook to /etc/pf.conf:
rdr-anchor "hostmux"
load anchor "hostmux" from "/etc/pf.anchors/hostmux"
And enable it:
sudo pfctl -e
sudo pfctl -f /etc/pf.confAlternative: run hostmux with elevated privileges. Not recommended for daily dev.
Binding :443 typically requires administrator privileges. Run hostmux
from an elevated shell, or set up a local port redirect with netsh
interface portproxy. Refer to Windows documentation for the exact
invocation on your version.
Create a TOML config (default: ~/.config/hostmux/hostmux.toml):
domain = "example.com"
# hide_port = true # omit the listener port from URLs printed by `hostmux url`/`run`
[tls]
listen = ":8443"
# A listen address without a host (":8443") binds loopback only — both
# 127.0.0.1 and ::1 — so your dev servers are not reachable from the LAN.
# cloudflared connects over loopback, so the tunnel workflow is unaffected.
# To serve the local network directly (e.g. a phone on the same Wi-Fi
# without a tunnel), set an explicit host: listen = "0.0.0.0:8443".
# Optional: override the managed self-signed certificate paths.
# cert = "~/certs/hostmux.crt"
# key = "~/certs/hostmux.key"
[[app]]
hosts = ["api"]
upstream = "http://127.0.0.1:8080"
[[app]]
hosts = ["admin", "myapp.example.org"]
upstream = "http://127.0.0.1:9000"hostmux serves HTTPS only. Set http_redirect to a plain-HTTP listen address
and hostmux answers every request there with a 308 redirect to the matching
HTTPS URL (preserving path, query, and method), so http://app.example.com
lands on https://app.example.com:
http_redirect = ":8080" # empty/omitted disables the redirect listenerLike the main listener, an address without a host binds loopback only; set an
explicit host (e.g. 0.0.0.0:8080) to answer redirects on the LAN. This
setting is applied at daemon start; unlike routes, changing it requires a
restart.
Run with hostmux start --config /path/to/hostmux.toml. The file is hot-reloaded on save.
hostmux applies conservative server-side defaults out of the box — a
read_header_timeout of 10s and an idle_timeout of 120s (anti-Slowloris)
— and otherwise uses Go's standard transport settings, which suit local
development. When hostmux fronts apps over a tunnel you can tighten or relax
these limits with a [proxy] block. Every field is optional; a value here
overrides the corresponding default. Note: the [proxy] block is applied at
daemon start, so changes require a restart (it is not hot-reloaded like
routes).
[proxy]
# Server-side limits.
read_header_timeout = "10s" # max time to read request headers (anti-Slowloris)
idle_timeout = "120s" # max idle keep-alive lifetime
max_header_bytes = 1048576 # cap request header size (bytes); 0 = Go default (1 MiB)
# Upstream transport.
dial_timeout = "10s" # max time to connect to an upstream
response_header_timeout = "30s" # max wait for upstream response headers -> 504 on timeout
# Disable TLS verification for HTTPS upstreams that present self-signed
# certs. Off by default; enable only for trusted local dev servers.
upstream_insecure_skip_verify = falseDurations are TOML strings such as "5s", "500ms", or "2m". On an
upstream timeout the proxy returns 504 Gateway Timeout; on a refused or
unreachable upstream it returns 502 Bad Gateway.
Enable per-request access logging to diagnose 404s (host routing), 502s
(upstream down), and 504s (upstream too slow). Logs go to the daemon's
stderr, so they appear in the foreground (hostmux start --foreground) or
wherever the detached daemon's stderr is captured.
access_log = true
log_format = "text" # "text" (default) or "json"Each line records method, host, path, status, latency, upstream, and source
(config, socket:N, or manual:NAME). Request headers and bodies are
never logged, so credentials and payloads stay out of the logs. Example:
access GET api.example.com/v1/users -> 200 (4.1ms) http://127.0.0.1:8080 src=socket:3
The json format emits one object per line for ingestion by log tooling.
Like [proxy], this setting is read at daemon start (not hot-reloaded).
Most of this codebase was written with LLM agents. The architecture, edge case handling, and test coverage reflect that. It runs in production and the integration test catches regressions, but you should know how it was made.
Apache 2.0. See LICENSE.