Project

yamine

0.0
The project is in a healthy, maintained state
Gives every Ruby app a stable https://<app>.localhost URL instead of a memorized port. Explicit-run reverse proxy with zero-config name inference, git-worktree variants, per-host TLS, and agent-friendly list/get/doctor commands. Ruby stdlib only.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.25
~> 3.1
~> 13.0

Runtime

~> 0.2
 Project Readme

yamine

Gem Version

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.localhost

Why "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 install

The 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   # Linux

yamine 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                 # Linux

Open 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: false

Optional 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 merged

add 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:

  1. Component-form development/test config. Environment overrides — injected DATABASE_URL/NAME_DATABASE_URL and the per-worktree .env — only apply to component keys (database:, host:). A url: 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.

  2. A dotenv loader — gem "dotenv-rails", groups: [:development, :test] (any dotenv loader works). That is what reads the files worktree add writes into the worktree:

    • .env.development — the whole development set (DATABASE_URL plus one NAME_DATABASE_URL per configuration),
    • .env.test — the test URL under PRIMARY_DATABASE_URL, the key Rails checks before DATABASE_URL for 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 .env in 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) survive yamine db create. A hand-run rails console, rails test, or db:migrate in the worktree therefore lands on the worktree's own databases — with zero yamine-specific code in database.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 subdomains
yamine alias tenant1 4001 --wildcard   # one route, its subdomains

Off 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 Kamal

Child 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.txt stops the backend. A managed socket app then reboots on the next request; a yamine start app has to be started again (yamine restart says 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_TIMEOUT seconds; 0 disables) 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, .powrc env 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 kamal preview-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 test

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.
  • LAN/mDNS or tunneled sharing. mDNS behaves differently on every network; third-party tunnels need CLIs, auth state, and accounts. The kamal preview-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