Skip to content

Repository files navigation

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 sudo once per machine; 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 trusts the CA, installs the service, syncs hosts, and verifies:

yamine setup

Agent / CI (no TTY): the same commands fail fast with guidance, because sudo needs a terminal. To pre-provision a machine or image so agents can install the service 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 scoped to yamine's own service re-exec — the gem's exact ruby + bin path with the service install --internal / service uninstall --internal subcommands — never a bare interpreter. Re-run it after upgrading the gem if the install path changes. To undo: sudo rm /etc/sudoers.d/yamine.

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 --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 (+ backend 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 (managed apps reboot)
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.

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

Managed apps are supervised by the proxy daemon, not the CLI:

  • idle backends stop after 15 minutes (YAMINE_IDLE_TIMEOUT seconds; 0 disables) and boot transparently on the next request
  • touching tmp/restart.txt stops the backend; next request reboots it
  • crashed backends are detected and rebooted on the next request
  • daemon shutdown stops every supervised backend (no orphans)

Run-mode (TCP) routes and static aliases are never supervised.

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 yamine-apps fixture fleet (sibling checkout) exercises detection, inference, and boot across Rails variants, Roda, Sinatra, bare Rack, Jekyll, compound Procfiles, and a monorepo. CI runs the fixture sweep automatically.

License

MIT

About

Stable named .localhost URLs for Ruby development — the local half of the Kamal pair

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages