Run multiple Rails apps simultaneously on pretty .localhost subdomains.
~/code/my-app $ bin/dev → https://my-app.localhost
~/code/another-app $ bin/dev → https://another-app.localhost
~/code/wiki $ bin/dev → https://wiki.localhost
~/code/blog $ bin/dev → https://blog.localhost
All four servers run at the same time. Each Rails app picks a free port automatically. You don't have to type ports in the URL. You don't have to remember which app is on which port.
Caddy is the reverse proxy. It listens on :443,
holds a local CA, and auto-issues HTTPS certificates for *.localhost.
local_domain is a thin Ruby client. A Railtie prepends Rails::Server#start,
so when bin/dev (or bin/rails server) boots:
- Reads the current folder name (e.g.
my-app). - Picks a free TCP port in
3000..3999(prefers 3000 when it's free). - POSTs a route to Caddy's admin API:
host = my-app.localhost,reverse_proxy 127.0.0.1:<port>. - Mutates the server's bind options so Puma listens on that port.
- On shutdown, removes the route via Caddy's admin API.
There is no daemon to manage other than Caddy itself.
brew install caddy
gem install local_domain # or add to a Gemfile group below
local_domain setupThe setup command writes a minimal Caddy config and a launchd plist into
~/.local_domain/, then prints the two sudo commands you need to run once:
sudo caddy trust # trust the local root CA
sudo cp ~/.local_domain/com.local-domain.caddy.plist /Library/LaunchDaemons/
sudo launchctl bootstrap system /Library/LaunchDaemons/com.local-domain.caddy.plistAfter that, Caddy starts at boot, listens on :443, and waits for local_domain
to register subdomains.
If you already have Caddy running with its own config, local_domain will add
its routes to whatever HTTPS server is already there (auto-detected) rather than
clobbering your existing setup.
Add the gem to your Rails app's Gemfile. That's it. No Procfile.dev edits,
no config/puma.rb changes:
group :development do
gem "local_domain", github: "web-ascender/local_domain"
end(Not yet on RubyGems — pull straight from GitHub for now. You can pin a
specific commit or tag with ref: / tag: / branch: once we cut releases.)
Then bundle install, run bin/dev (or bin/rails server), and visit
https://<your-folder-name>.localhost.
The Railtie does two things:
- Prepends
Rails::Server#startto override the port and register/unregister the Caddy route around server lifetime. - Appends
<folder>.localhosttoconfig.hostsso Rails' DNS rebinding protection doesn't 403 the proxied request.
Your terminal tab is also automatically renamed to the subdomain (e.g.
my-app.localhost) so you can tell which app each tab is running. Works in
iTerm2, Terminal.app, Ghostty, kitty, alacritty, and most modern terminals.
Set LOCAL_DOMAIN_NO_TITLE=1 to opt out.
If you ever need to bypass the hook entirely (run on the original port, no Caddy):
LOCAL_DOMAIN_DISABLE=1 bin/devlocal_domain setup Write Caddy config + launchd plist and print next steps.
local_domain serve [args] Pick a port, register with Caddy, then exec `rails server`.
local_domain status Show currently registered subdomains and upstream ports.
local_domain stop <subd> Unregister a subdomain from Caddy.
local_domain version
local_domain help
Environment variables:
| Var | Default | Meaning |
|---|---|---|
LOCAL_DOMAIN_ADMIN_URL |
http://127.0.0.1:2019 |
Caddy admin API endpoint |
LOCAL_DOMAIN_TLD |
localhost |
TLD used for <folder>.<tld> |
LOCAL_DOMAIN_LISTEN |
:443 |
Port Caddy listens on (HTTPS) |
LOCAL_DOMAIN_BIND |
127.0.0.1 |
Host Rails binds to |
TLS handshake error the very first time you hit a new subdomain. Caddy issues a cert on demand; the first request occasionally beats the cert provisioning. Retry once.
"Caddy admin API not reachable". Caddy isn't running or it's running
without --config pointing at one that enables the admin endpoint. Re-run
local_domain setup to see what's expected, then start Caddy via the
launchd plist setup generated.
Rails 403 "Blocked host". The Railtie didn't load. Confirm local_domain
is in your :development group and that Rails.env.development? is true.
MIT