rb-portless
Stable, named .localhost URLs for local development —
a native-Ruby port of Vercel's portless.
- bin/rails server # http://localhost:3000
+ rb-portless run bin/rails server # https://myapp.localhost
Run your dev server through a tiny local reverse proxy and reach it at
https://<name>.localhost instead of juggling ports. HTTPS by default (a local
CA + per-host certs), a random backend port so you never collide on 3000/3001,
and wildcard subdomains (*.myapp.localhost) so multi-tenant apps Just Work.
Install
gem install rb-portless
- Ruby >= 3.2. macOS or Linux. (Windows: HTTP works; CA trust + boot service are on the roadmap.)
Use
rb-portless run bin/dev # -> https://<project>.localhost
rb-portless run bin/rails server # anything that respects $PORT
rb-portless run -- npm run dev # Vite/Astro/etc. get --port injected
A random port (4000–4999) is injected as PORT (and HOST=127.0.0.1);
Rails/puma respect it natively. The proxy auto-starts on first run: it
generates a local CA, trusts it (one keychain/sudo prompt, like portless),
and binds 443 — another one-time sudo on macOS/Linux (falls back to :1355 if
you decline). After that, HTTPS just works with no browser warnings.
rb-portless trust # re-trust manually if ever needed
rb-portless service install # bind 443 at boot — never prompt for sudo again
Config (portless.json)
{ "name": "shirabe", "tld": "shirabe.org.localhost" }
| Key | Default | Meaning |
|---|---|---|
name |
dir/git root | the subdomain label |
tld |
localhost |
base host; a multi-label value like shirabe.org.localhost gives every *.shirabe.org.localhost subdomain, all routed to the one app |
tls |
true |
HTTPS (false = plain HTTP on :80) |
appPort |
random | pin the backend port |
With a custom tld, every *.shirabe.org.localhost subdomain wildcard-routes to
the one app — ideal for subdomain-per-tenant apps.
Multiple apps, LAN & sharing
Monorepo / multi-app — define an apps map and rb-portless run (no command)
starts them all, each at its own name:
// portless.json
{ "apps": { "web": "bin/rails server", "api": "node api/server.js" } }
// → https://web.localhost, https://api.localhost
LAN mode — reach the app from your phone on the same Wi-Fi. It detects the
LAN IP, registers <name>.local, and publishes it over mDNS:
rb-portless run --lan bin/dev # also → https://<name>.local
rb-portless run --lan --ip 10.0.0.5 bin/dev # override the detected IP
Devices won't trust your local CA without installing it — use
--lanwith--no-tls(set"tls": false) for plain HTTP, or install~/.rb-portless/ca.pemon the device.
Public sharing (experimental) — expose the app via ngrok or your tailnet:
rb-portless run --ngrok bin/dev # https://xxxx.ngrok.app
rb-portless run --tailscale bin/dev # your-machine.tailnet.ts.net
rb-portless run --funnel bin/dev # tailscale Funnel (public)
These use the tools' own CLIs — install them separately (we don't bundle anything, same as portless), and each degrades gracefully if the tool is absent:
- ngrok — install the
ngrokCLI and configure an authtoken (free account). - tailscale — install
tailscale, be logged into a tailnet, and enable HTTPS certificates (and Funnel, for--funnel) in your tailnet settings.
Your tailnet config is safe. rb-portless reads
tailscale serve status, picks a free HTTPS port (never one you're already serving on), registers with--yes, and on exit turns off only the port it created — it never touches your existingserve/funnelsetup.
Commands
| Command | Does | ||
|---|---|---|---|
run <cmd> |
run a dev server through the proxy | ||
<name> <cmd> |
shorthand for run --name <name> <cmd> |
||
| `proxy start \ | stop` | manage the proxy daemon | |
trust |
install the local CA into the OS trust store | ||
| `service install \ | uninstall \ | status` | bind the privileged port at boot (launchd/systemd) |
alias <name> <port> [--force] |
a static route (Docker, Postgres, …); --remove to drop it |
||
get <name> [--no-worktree] |
print a name's URL (for $(rb-portless get api)) |
||
list |
show active routes (with any tailscale/ngrok URLs) | ||
| `hosts sync \ | clean` | manage /etc/hosts (Safari / non-.localhost TLDs) |
|
doctor |
diagnose setup | ||
prune [--force] |
reap stale routes and kill the orphaned dev server (--force = SIGKILL) |
||
clean |
stop the proxy, untrust the CA, remove all state |
Every subcommand takes --help. rb-portless <cmd> --help prints command-specific
help.
Run flags
| Flag | Does |
|---|---|
--name <name> |
override the inferred hostname |
--app-port <n> |
pin the backend port (else a random 4000–4999) |
--force |
take over a route already held by another run |
--lan [--ip <addr>] |
also serve on the LAN as <name>.local (mDNS) |
--ngrok / --tailscale / --funnel |
share publicly |
In a git worktree linked off a non-default branch, the branch name is prepended
as a subdomain — feature/auth → https://auth.<name>.localhost — so every worktree
gets a distinct URL. Pass --no-worktree (on get) to skip it. Set PORTLESS=0
(false/skip) to run the command directly without the proxy.
Rails
Rails is first-class: it respects PORT and trusts the loopback proxy, so
X-Forwarded-Host/Proto/Port flow through and request.host, subdomains, and
generated URLs all reflect https://<name>.localhost.
Zero-config setup. Add the gem to your dev group with the railtie required — that's the only project change:
# Gemfile
group :development do
gem "rb-portless", require: "portless/rails"
end
// portless.json (optional — name defaults to the dir/git root)
{ "name": "myapp", "tld": "myapp.localhost" }
rb-portless run bin/dev # → https://myapp.localhost (CA auto-trusted on first run)
That's it. The railtie auto-detects when you're running under rb-portless
(via the PORTLESS_URL env the runner injects) and only then whitelists your
*.localhost hosts in development — so Action Dispatch host authorization doesn't
403 your named subdomains. Run bin/dev normally and nothing is touched.
bin/devnote:rb-portless run bin/devwraps Foreman. Foreman passes the injectedPORTto the first process inProcfile.dev— keepweb:first (the Rails default) so the server binds the port the proxy registered.
Prefer not to add the gem? Skip the railtie and allow the host yourself:
# config/environments/development.rb
config.hosts << /.+\.localhost/
Use cases
Kill the port. Stop memorizing :3000 / :3001. One stable HTTPS URL per
app, the same every day:
rb-portless run bin/dev # https://myapp.localhost
Subdomain-per-tenant apps (the headline). A multi-label tld gives every
subdomain to one app, so multi-tenant / Classroom-style routing works locally
exactly like production:
{ "name": "myapp", "tld": "myapp.localhost" }
// kobe.myapp.localhost, osaka.myapp.localhost, admin.myapp.localhost → your app
Several services at once. Give each its own name; route non-portless
processes (a database, a container) with a static alias:
rb-portless run bin/dev # https://web.localhost
rb-portless run -- node api/server.js # https://api.localhost (in another tab)
rb-portless alias pg 5432 # https://pg.localhost (static)
HTTPS that matches prod. Develop against real TLS + HTTP/2, so secure-cookie
and X-Forwarded-Proto behaviour is the same locally as in production.
How it works
No daemon protocol — coordination is a state dir (~/.rb-portless) with a
routes.json registry (host → backend port). The proxy resolves each request's
host to a backend and forwards it, minting a per-host TLS leaf cert on the SNI
callback (because *.localhost wildcard certs aren't valid at the reserved-TLD
boundary). For ports < 1024 it re-execs under sudo so the elevated process can
bind the socket, then hands ownership of state files back to you. See
AGENTS.md for the full architecture.
Compared to portless (Node)
The mental model is identical — run wraps your dev command, the proxy
auto-starts, named .localhost URLs replace ports. The only Ruby-world addition
is the one-line require "portless/rails" to satisfy Rails' host authorization.
| portless (Node) | rb-portless (Ruby/Rails) | |
|---|---|---|
| Install | npm i -g portless |
gem install rb-portless (or Gemfile dev group) |
| Run a server | portless run next dev |
rb-portless run bin/rails server |
| Run the dev orchestrator | portless (runs "dev" script) |
rb-portless run bin/dev (wraps Foreman) |
| Bake into the project | "dev": "portless run next dev" → npm run dev |
put rb-portless run in bin/dev, or use the binstub |
| Name the URL | portless myapp … / portless.json |
portless.json { "name": "myapp" } (else dir/git root) |
| Wildcard tenant subdomains | tld config |
portless.json { "tld": "myapp.localhost" } → *.myapp.localhost |
| Pin the backend port | --app-port / appPort |
appPort in portless.json |
| Framework port injection | vite/astro/etc. auto | same (Rails/puma respect PORT natively) |
| HTTPS trust | auto on first run (+ portless trust) |
auto on first run (+ rb-portless trust) |
| Host allowlist | not needed | gem "rb-portless", require: "portless/rails" (Rails-only) |
| Privileged 443 bind | sudo re-exec (auto) | sudo re-exec (auto), :1355 fallback |
| Bind at boot (no sudo) | portless service install |
rb-portless service install |
| Inspect / manage | portless list / doctor / clean |
rb-portless list / doctor / clean |
| Static route (DB, etc.) | portless alias pg 5432 |
rb-portless alias pg 5432 |
Contributing
bundle install
bundle exec rake test
bundle exec rubocop
License
MIT.