yamine
Stable named .localhost URLs for Ruby development. Gives every Ruby app
a stable https://<app>.localhost URL instead of a memorized port.
Zero runtime dependencies — Ruby stdlib only (openssl, socket).
gem install yamine
yamine start # setup + boot in one go (or plain `yamine`)
yamine setup # once per machine: CA trust + port 443 + hosts + verify
cd ~/code/myapp && yamine
# -> https://myapp.localhostWhy "yamine"?
Yamine (يمين, yamīn) is Arabic for "right hand" — the side of blessing and good fortune, the hand you keep things close with. It sits next to Kamal (كمال, kamāl, "completeness, perfection"): Kamal completes the deploy, Yamine keeps the app at your right hand — local, close, yours.
And yes, if you follow football: Yamine Kamal is Lamine Yamal with the names flipped. I'm a fan — the kid who nutmegs entire defenses at sixteen is exactly the energy local dev should have. The local half of the game, played the same way: fast, fearless, and fun to watch.
(For the curious: yamine is pronounced "ya-MEEN".)
The no-fallback promise
yamine never silently degrades to a :<port> URL. Clean
https://<app>.localhost requires the proxy on port 443; if 443
cannot be bound, you get a hard error pointing at yamine setup
— never a booted app on https://app.localhost:1355 that silently
poisons OAuth callbacks, mailer hosts, and webhooks downstream.
The only port-suffixed URLs are the ones you explicitly ask for:
yamine proxy start -p 1355 (CI/sandboxes where 443 is impossible).
There the suffix is honest, and YAMINE_URL carries it faithfully.
Because the port is recorded machine-wide, yamine refuses to let a
one-off -p outlive its process: a recorded port is reused only while
something is actually listening on it. Stop that proxy and the next
yamine run goes back to the clean default (443) instead of quietly
raising another proxy on 1355. yamine doctor and yamine start both
say so out loud when a running proxy is on a non-default port — the
[warn] line names the port, the :PORT it puts in every URL, and how
to get back to 443.
Port 443: one-time setup, then never again
Binding 443 is privileged, so yamine installs a root-owned launchd
service (macOS) or systemd unit (Linux) that binds 443 at boot — the
same model as puma-dev and portless. Installing it needs an interactive
sudo once per machine (one Touch ID tap); after that, every yamine
run in any project gets a clean https://<app>.localhost with no
elevation and no prompt.
Human (interactive): run setup once — it stages a root-owned copy of yamine, installs the service, trusts the CA, syncs hosts, and verifies:
yamine setup
# or, for just the service: sudo yamine service installThe unit runs a staged payload, not your gem directory: the install
copies yamine's bin+lib into a root-owned directory at a stable,
version-independent path (/Library/Application Support/yamine on
macOS, /usr/local/share/yamine on Linux), verifies root ownership and
modes fail-closed (a bad tree registers nothing), and pins that path in
the unit. Ordinary user-space writes — a gem upgrade, a bundle install, an agent editing the gem — can no longer change what runs as
root, and upgrades never stale the setup. yamine doctor names a legacy
install (a unit still pointing at a user-writable gem path) with the
exact migration, plus staged-vs-CLI version skew and any ownership
problem.
Agents: the steady-state grant. New project ⇒ new hostname ⇒ Safari
needs the /etc/hosts entry; that recurring privileged need stays
agent-invocable without a prompt. Install the scoped passwordless-sudo
rules once (as an admin):
yamine sudoers > /tmp/yamine.sudoers
sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine # macOS
sudo install -o root -g root -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine # Linuxyamine sudoers prints rules for the staged payload only — hosts sync (hostnames strictly validated: well-formed, written only inside
yamine's managed block, and only under .localhost or an allowlisted
domain) and service uninstall (which only deletes yamine's own
files). Installing or upgrading the staged payload moves user-writable
source into root-owned paths, so it deliberately stays a human
interactive sudo — no grant will ever cover it, and a non-interactive
service install fails fast saying so. To undo the grant:
sudo rm /etc/sudoers.d/yamine.
If you installed an older yamine, your /etc/sudoers.d/yamine may still
pin a version-stamped gem path (.../gems/yamine-X.Y.Z/...) including
an install rule — re-run yamine sudoers and replace the file. The old
rules go stale every release (which pushed people toward NOPASSWD: ALL); the new ones survive upgrades.
Custom domains (e.g. proxy.host: myapp.local.example.com for OAuth
parity) need their parent domain allowlisted once by a human — hosts sync refuses anything outside .localhost and the staged allowlist,
because an unvalidated root hosts write could point a real vendor domain
at loopback:
echo 'local.example.com' | sudo tee -a "/Library/Application Support/yamine/allowed-tlds" # macOS
echo 'local.example.com' | sudo tee -a /usr/local/share/yamine/allowed-tlds # LinuxOpen caveat: the interpreter. The unit runs the invoking Ruby, and
there is no root-owned Ruby ≥ 3.2 on a stock machine (/usr/bin/ruby is
2.6), so the daemon necessarily runs a user-writable interpreter today.
The payload is root-owned; the interpreter is not — yamine doctor
reports its path and writability truthfully. Anything that can write
that Ruby can change what runs as root. This hole stays open until the
machine has a root-owned Ruby new enough for the gem; yamine will not
vendor or stage an interpreter to pretend otherwise.
The one-file model
Every app declares config/local.yml (Kamal-style) — the single source
of truth for service name, proxy TLD/host, processes, and env:
service: myapp
proxy:
tld: localhost
subdomains: false # opt in to answering *.myapp.localhost
processes:
web:
cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
proxy: true
worker:
cmd: bundle exec sidekiq
proxy: falseOptional top-level db: false opts out of per-worktree databases
(exotic setups — manual establish_connection, shared staging DB, …);
db.schema_load overrides the schema-load command. Per-process
healthcheck: { path: /up, timeout: 30 } declares what --wait polls
(TCP accept when absent). The poll is plain HTTP against the app's own
127.0.0.1:$PORT listener — TLS is terminated by the proxy, so the path
is reached over http regardless of the https:// URL in the banner.)
yamine init creates the file (migrating an existing Procfile);
Rails apps need no extra gem — yamine injects RAILS_DEVELOPMENT_HOSTS
so the proxied hostname is allowed automatically. yamine
then boots every process, assigns each a $PORT, injects
YAMINE_URL, registers routes for HTTP processes, supervises the
whole tree, and cleans up when one exits.
Variants are file overlays: config/local.<variant>.yml deep-merges on
top of config/local.yml, selected by YAMINE_VARIANT (Kamal's
destination pattern). Naming a variant also prefixes the hostname — see
below for how that differs from a worktree's automatic prefix.
.localhost resolves to loopback natively in Chrome, Firefox, and Edge —
no DNS server, no /etc/resolver. Safari, custom TLDs, and resolvers
that read only /etc/hosts (CGO-disabled Go binaries are the common
case) need yamine hosts sync.
Hostname shape
{variant}.{service}.{app}.{tld}
| Axis | Example | Source |
|---|---|---|
| app | myapp |
service: in config/local.yml (yamine init infers it) |
| service | api.myapp |
a non-web process name (every proxy: true process but web) |
| variant | fix-ui.myapp |
--variant, YAMINE_VARIANT, linked worktree branch |
| tld | myapp.preview.example.com |
--tld (default localhost) |
proxy.host is the one exception: an explicitly written full hostname
bypasses composition entirely, variant included.
Linked git worktrees get a branch prefix automatically
(ui-onboarding.myapp.localhost); the main checkout keeps the bare name,
so a worktree and its main checkout run side by side. The label is the
whole branch (feature/login → feature-login.myapp.localhost), so two
branches that share a last segment never share a hostname. A detached
HEAD has no branch to name it and falls back to the worktree's directory
— the same identity its per-worktree database uses. main/master
never prefix. Pass --branch (or YAMINE_BRANCH=1) to prefix by the
current branch outside worktrees.
A variant is a hostname label, not a config file. Only an explicit
--variant / YAMINE_VARIANT goes looking for
config/local.<name>.yml to merge; a worktree branch never does, so a
branch named like a file on disk cannot change your config by accident.
yamine status prints both lines so the two are never confused.
Worktree lifecycle
yamine owns a worktree from creation to removal:
yamine worktree add feature/login # worktree + config + database, ready to boot
yamine worktree list # every worktree: db, dirty, merged
yamine worktree remove feature/login # stop, drop db, remove worktree
yamine worktree clean # tear down everything already mergedadd lands the worktree beside the repo, copies the gitignored
per-checkout config (config/local.yml, config/local.secrets,
config/master.key, and the development/test credential keys) the branch needs, runs
bundle install, asks the app what databases it has, and provisions
the whole set with schema — the next step is just yamine start in
it.
clean is the done-and-merged sweep: it tears down every worktree
whose branch is merged (stop, drop database, remove worktree, delete
branch) and forgets claims of directories that no longer exist. It
never touches a worktree with uncommitted changes, and unmerged
branches survive every path except remove --force — git branch -d
refuses to delete what git has not seen merged, so a wrong merge
detection cannot lose a branch. --all includes clean-but-unmerged
worktrees (the branch stays); --dry-run prints the plan; remove --force is the only command that discards uncommitted changes.
yamine # -> https://myapp.localhost
yamine --variant demo # -> https://demo.myapp.localhost
yamine --tld preview.example.com # your own domain (OAuth parity)Multi-database apps
A Rails multi-database app (five databases is normal: primary, cache,
queue, cable, errors) gets the whole set per worktree — asked, not
guessed. worktree add boots bin/rails runner once inside the app so
database.yml and credentials resolve exactly as the app would resolve
them (yamine never parses config or touches a key), suffixes every
database name with a collision-guarded per-worktree token, creates and
schema-loads them, prepares the test database, and records names plus
server coordinates in the claim. Boot injects DATABASE_URL and one
NAME_DATABASE_URL per configuration (Rails' own convention).
remove/clean drop the entire set as a unit — including from an
orphaned claim whose directory is already gone, without the app
booting.
yamine db describe # what THIS checkout resolves to (passwords masked)
yamine db list # every claim, every database under it
yamine db create # re-probe + provision (run after the app grows a database)Two one-time app requirements, both boring:
-
Component-form development/test config. Environment overrides — injected
DATABASE_URL/NAME_DATABASE_URLand the per-worktree.env— only apply to component keys (database:,host:). Aurl:key (the usual credentials-driven style) takes precedence over the entire environment: Rails skips URL-shaped configs when merging environment variables, so nothing injected or loaded can redirect them. Development and test should read like a plain Rails file:default: &default adapter: postgresql encoding: unicode host: <%= ENV.fetch("DB_HOST", "localhost") %> username: postgres development: primary: <<: *default database: myapp_development cache: <<: *default database: myapp_development_cache migrations_paths: db/cache_migrate staging: primary: &primary_staging <<: *default url: <%= Rails.application.credentials.dig(:database, :primary, :url) %>
Staging/production keep doing whatever they do — yamine only ever redirects development and test.
-
A dotenv loader —
gem "dotenv-rails", groups: [:development, :test](any dotenv loader works). That is what reads the filesworktree addwrites into the worktree:-
.env.development— the whole development set (DATABASE_URLplus oneNAME_DATABASE_URLper configuration), -
.env.test— the test URL underPRIMARY_DATABASE_URL, the key Rails checks beforeDATABASE_URLfor a flat test config, so it wins regardless of the order a loader reads the files in.
Only these environment-scoped names are ever written — never plain
.env, the file production tooling reads by name (kamal, docker--env-file, and dotenv itself loads.envin every environment), so a production boot can never see a worktree's database URLs. Mode 0600, git-excluded automatically, removed with the worktree. Existing keys in those files are upserted, never clobbered — your own entries (API keys, a committed env file's config) surviveyamine db create. A hand-runrails console,rails test, ordb:migratein the worktree therefore lands on the worktree's own databases — with zero yamine-specific code indatabase.yml, ever. -
worktree add proves both after writing the files: you will see
.env loaded — hand-run commands are isolated too. A warning instead
names which requirement is missing — no loader ran, or the config is
url:-shaped — and the exact fix. Supervised boots are isolated via
injected env either way (component form permitting).
Credential keys travel too: config/master.key,
config/credentials/development.key, and
config/credentials/test.key are copied at mode 0600. Production and
staging keys stay in the main checkout where they belong.
The main checkout never gets these env files or a suffix: its databases are its databases, untouched.
Subdomains are opt-in
A route answers its exact hostname. *.myapp.localhost reaches
myapp.localhost only if that app asked for it:
proxy:
subdomains: true # this app answers its own subdomainsyamine alias tenant1 4001 --wildcard # one route, its subdomainsOff is the useful default. An unregistered label under a live app is far
more likely to be a worktree whose stack is stopped than a tenant, and
handing that label to the parent app means HTTP 200 with the wrong code.
Instead the request fails with 503 and names the parent app, its directory,
and how to start it — the app is not there, which is not the same statement
as the app answering 404. yamine status reports which mode an app is in.
Commands
yamine # boot app (waits until healthy, then supervises)
yamine start --detach # boot in the background; returns once healthy, prints url/pid/log
yamine start --no-wait # fire-and-forget (register routes immediately)
yamine start --json # machine-readable wait result (--wait default)
yamine get <name> # print URL for cross-service wiring
yamine alias <name> <port> # static route (e.g. Docker)
yamine list [--json] # show active routes (+ the APP's liveness)
yamine status [--json] # show effective naming context here
yamine doctor [--json] # machine-readable health checks
yamine open [name] # open the app URL in a browser
yamine trust # add local CA to system trust store
yamine clean # remove state and hosts entries
yamine prune # remove stale routes
yamine db list|create|drop|describe # per-worktree databases (multi-database aware)
yamine worktree list|add|remove|clean # worktree lifecycle
yamine stop # stop this app's backend + routes
yamine restart # touch tmp/restart.txt (a supervised app's backend is stopped)
yamine log [-F] [n] # tail (or follow) log/development.log
yamine proxy start|stop # control the proxy
yamine service install|status|uninstall # root-owned OS startup service
yamine hosts sync|clean # manage /etc/hosts entries
yamine kamal <variant> # preview-deploy snippet for KamalChild processes receive YAMINE_URL (the stable URL — use it for
OAuth callbacks, mailer hosts, webhook URLs), PORT, and HOST.
Frameworks
Rails and bare Rack (config.ru) boot managed on a unix socket (Puma
when available; rackup on TCP otherwise). Port-ignoring CLIs —
Jekyll, Bridgetown, Middleman — get explicit --port/--host flags
injected at boot; everything else comes from processes: in
config/local.yml, one process per entry.
Process commands that are compound (&&, ||, |, ;) are refused
with guidance rather than silently mis-injected.
For Rails integration (hosts, Action Cable origins, Procfile rewrite, generators) — deprecated; core covers Rails now.
WebSockets
Action Cable and any Rack hijack-based WebSocket server work through the
proxy: HTTP/1.1 Upgrade requests are byte-forwarded to the backend
after header rewriting, and the tunnel stays raw for the life of the
connection (verified end-to-end: RFC 6455 handshake + frame echo).
Machine-readable output
list, status, doctor, and yamine start --wait accept --json with stable keys for
agents and scripts (DevUrl in ask-ruby-harness consumes the same data
in-process). Hostnames that fall outside the configured TLDs get a bare
404 naming nothing — route names never leak to foreign hosts
(DNS-rebinding boundary).
yamine start (default --wait) exits 0 only once every HTTP route is healthy
(healthcheck path when declared, TCP accept otherwise). On failure it
exits 1 with the failed process, its phase, and the tail of its own log
— no guessing, no polling, no half-booted routes. --no-wait keeps the
old fire-and-forget path. A child that dies later ends the run the same
way: exit 1, naming the process, its exit status (or the signal that
took it down) and the tail of its log. Zero means the app is up.
yamine start --detach boots the same tree into the background and
returns once it is healthy, printing the URL, the pid that owns the tree
and its log path under the state dir (start-<hostname>.pid /
start-<hostname>.log). The boot runs in the child, so that pid is the
one recorded in routes.json — the route outlives the command. It is
idempotent (a tree already running for the directory is reported, not
restarted), it ignores --no-wait (returning before the app answers is
the thing it exists to prevent), and --json returns
{ok, url, pid, log_path, started}.
Log rotation
proxy.log rotates at 5MB (YAMINE_LOG_MAX_BYTES), keeping one
generation. log/development.log is rotated too — use
tail -F log/development.log (or yamine log -F) so rotation doesn't
lose the tail. doctor warns when the state dir passes 100MB.
Supervision
Apps are supervised by the proxy daemon, not the CLI. A route is
supervised when it names a directory to boot from (spec.dir), which
is every app yamine boots — managed socket apps and yamine start
trees. Static aliases are not.
- touching
tmp/restart.txtstops the backend. A managed socket app then reboots on the next request; ayamine startapp has to be started again (yamine restartsays which one you have) - crashed backends are detected, and a managed app is rebooted on the next request
- a managed app idle-kills after 15 minutes (
YAMINE_IDLE_TIMEOUTseconds;0disables) and boots transparently on the next request - daemon shutdown stops the backends the daemon booted (no orphans)
A yamine start app is watched but not idle-killed, and is not
stopped when the proxy stops. Idle-kill is the half of puma-dev that
depends on the other half — "stopped, boots on next request" — and the
daemon can only keep that promise for a socket app: a tcp route's port
is a free port chosen at boot and the route records no command to
re-run, so nothing could bring it back. Set YAMINE_IDLE_TCP=1 to get
the puma-dev behaviour for yamine start apps too (same
YAMINE_IDLE_TIMEOUT clock — the supervisor's, distinct from the
proxy's own YAMINE_PROXY_IDLE_TIMEOUT).
Ask ecosystem integration
| Gem | How yamine helps |
|---|---|
ask-rails |
yamine core injects RAILS_DEVELOPMENT_HOSTS; Cable origins + helpers live in the deprecated yamine-rails |
ask-rails-harness |
Its 9 Rails tools (routes, models, DB, logs) run against the app the proxy serves; DevUrl gives the agent the stable URL instead of a guessed port |
ask-app-server |
The JSON-RPC/stdio session host sits behind https://api.<app>.localhost; editor/IDE clients use yamine get output |
ask-mcp |
MCP servers get named URLs per service (mcp.<app>.localhost), no port coordination across servers |
ask-skills |
Ships the yamine skill (auto-discovered): boot via yamine, wire via get, callbacks from YAMINE_URL
|
ask-ruby-harness |
DevUrl tool: structured list/get for agents, audit-logged like every other tool |
What we do differently from Kamal for local dev: Kamal + kamal-proxy
own production (Let's Encrypt, zero-downtime deploys, multi-host).
yamine never serves prod — but the variant slug is shared, so
fix-ui.myapp.localhost locally and myapp-fix-ui.preview.example.com
in staging (via yamine kamal fix-ui) are the same branch everywhere.
Prior art
Same problem, three generations — yamine borrows from all of them:
-
Pow (2011–2017, macOS-only Rack): the ergonomics — zero-config
names,
tmp/restart.txt,.powrcenv loading. Left behind: Nack workers, firewall forwarding, HTTP-only, unmaintained. - puma-dev (Go, macOS/Linux): the engine semantics — Puma on unix sockets, lazy boot, idle kill, restart.txt watching, in-memory dynamic TLS, per-route status. Kept as behavior, reimplemented in Ruby.
-
portless (Node 24, any stack): the agent interface — explicit run
ownership,
PORTLESS_URL-style env contract,get/doctor/prune, worktree prefixes, custom-TLD OAuth parity, SKILL.md pattern.
Build vs borrow decision: yamine is pure Ruby (stdlib + base64),
not a wrapper around puma-dev's Go core. Rationale: zero-toolchain
distribution (gem install, no Go/Node), the ask-core zero-dependency
philosophy, and full control over the agent surface (route store,
supervision, skills). puma-dev's semantics were ported, not its binary.
Non-goals (deliberate)
- HTTP/2. Ruby dev servers serve a handful of requests, not Vite's hundreds of unbundled files — the multiplexing win doesn't apply, and ALPN/HPACK/stream state would triple the proxy's auditable surface. Revisit only on benchmarked HMR latency. (Consequence: no HTTP/2 extended-CONNECT bridging; browsers never negotiate h2 here, so plain Upgrade tunneling covers Action Cable fully.)
-
LAN/mDNS and Tailscale/ngrok tunnels. mDNS behaves differently on
every network; tunnels need third-party CLIs, auth state, and accounts.
The 95% "show this branch to someone" case is covered by the
kamalpreview-deploy snippet on real infrastructure instead of a laptop tunnel. Kamal owns remote access; yamine owns local naming. - Production serving. The proxy binds loopback only, the CA is self-signed, and there is no request buffering, rate limiting, or access control. Anything real goes through Kamal + kamal-proxy.
Development
bundle install
bundle exec rake testNon-goals (deliberate)
- HTTP/2. Ruby dev servers serve a handful of requests, not Vite's hundreds of unbundled files — the multiplexing win doesn't apply, and ALPN/HPACK/stream state would triple the proxy's auditable surface.
-
LAN/mDNS or tunneled sharing. mDNS behaves differently on every
network; third-party tunnels need CLIs, auth state, and accounts.
The
kamalpreview-deploy snippet covers "show this branch to someone" on real infrastructure instead. - Production serving. The proxy binds loopback only, the CA is self-signed, and there is no buffering or rate limiting.
The test/fixtures/apps/ fixture fleet exercises
detection, inference, and boot across Rails variants, Roda, Sinatra,
bare Rack, Jekyll, compound Procfiles, and a monorepo. It ships with
the repo and runs as part of the unit suite across the Ruby matrix —
no sibling checkout, no separate CI job.
License
MIT