Skip to content

Environment files (.env)

This project has ten .env* files plus .envrc. Each has a different consumer and a different lifetime. This doc explains what each is for, who loads it, and how direnv ties them together.

File Tracked? Loaded by Purpose
.env gitignored Rails (Dotenv::Rails, dev/test only) App-level dev secrets — Cloudflare/HF/Vultr API tokens, etc. Auto-loaded into ENV on Rails boot.
.env.local gitignored Rails (Dotenv::Rails, test only) Per-developer overrides during test runs. Unshifted into the dotenv load path only when RAILS_ENV=test (config/application.rb:34).
.env.production.local gitignored Hand-sourced into a shell to run prod-mode commands locally Prod DB credentials (host, user, names) + DF_ENV=production, CONFIG_ENV=production. ⚠️ Prefer Kamal for anything touching prod. We’re on Kamal: run code/scripts against production with kamal app exec --reuse --roles=web 'bin/rails runner …' (see kamal/MANAGING.md § “Running code / scripts against production”), and for DB-only access query directly with psql "$POSTGRES_PROD_URI" / "$POSTGRES_REPLICA_URI" from .env.mcp. A bare mise exec -- bin/rails runner WITHOUT sourcing this file hits your local dev DB even with RAILS_ENV=production — a silent no-op on prod. This file exists only for the rare case you deliberately want a local prod-mode shell. Not used by the prod app — production env vars are injected by Kamal from config/deploy.yml (env.clear / env.secret, secrets resolved from 1Password via .kamal/secrets*).
.env.wasabi gitignored bin/db restore (Wasabi fallback only) Wasabi S3 credentials for the legacy fallback fetch in bin/db restore (BACKUP_SOURCE=wasabi). The default source is now Databasus/R2, which reads its read-only creds from 1Password directly (no env file). Stand-alone — not auto-loaded by Rails.
.env.mcp gitignored direnv (via .envrc) Team-shared MCP-server secrets (Postgres prod URIs, AppSignal token, Ahrefs token, Clarity, KeywordsPeopleUse, SVGMaker, Vultr, etc.). Source of truth: 1Password vault IT. Regenerated by script/setup_mcp_servers.sh.
.env.mcp.example tracked Documentation/template for .env.mcp. Lists every variable with a comment pointing at its 1Password source.
.env.mcp.local gitignored direnv (via .envrc, loaded after .env.mcp so it wins on conflict); also sourced by bin/claude-desktop Per-developer MCP secret overrides. Holds OP_SERVICE_ACCOUNT_TOKEN (the 1Password deploy service account — auto-written by script/setup_mcp_servers.sh from op://IT/1password-heatwave-ops, used by bin/deploy). ⚠️ Once set, direnv exports it into every project shell, so all op in the project routes through the SA (vault IT, read-only) — op for other vaults / writes won’t work in-project (cd out or unset). GitHub access is handled via the gh CLI’s own auth.
.env.mcp.local.example tracked Template for .env.mcp.local.
.env.db.local gitignored direnv (via .envrc) Optional per-worktree development DB clone selected by bin/db branch. Never symlinked from main.
.env.test.local gitignored direnv (via .envrc), bin/test-db Per-worktree primary + versions test DB names. Written for every worktree even when development uses the shared DB. Never symlinked from main.
.envrc tracked direnv Top-level direnv config that sources MCP, development-DB, and test-DB selectors. Also auto-runs bin/setup-worktree once on a fresh worktree (when .env.mcp is missing).

Related local config (not part of the dotenv chain):

File Tracked? Loaded by Purpose
.pr-ready.local gitignored bin/pr-ready Per-developer reviewer panel (PR_READY_REVIEWERS=codex,kimi or none). Written by bin/setup --reviewers. Worktrees symlink it from main. --only / --deep override one run.
.pr-ready.local.example tracked Template for .pr-ready.local.

Dotenv::Rails is auto-required because dotenv is in the development, test Gemfile groups (Gemfile:243). It loads the standard dotenv chain into ENV before Rails initializers run:

  • .env, .env.development, .env.development.local
  • .env.test, .env.test.local
  • .env.local (test only, due to config/application.rb:34)

This is why CLOUDFLARE_API_TOKEN, HUGGING_FACE_TOKEN, etc. just work in Rails.env.development? without any explicit source step.

direnv runs .envrc whenever you cd into the project — but only after you’ve trusted that .envrc once with direnv allow. Once allowed, it auto-loads on every directory entry (no per-cd command needed). .envrc sources .env.mcp, .env.mcp.local, .env.db.local, then .env.test.local, exporting every variable into the calling shell.

This is what makes ${POSTGRES_PROD_URI} etc. resolve in .mcp.json when Claude Code / Cursor / Zed spawn MCP child processes from the project dir.

Production runs on Kamal (Docker): the web container is Puma fronted by Thruster (./bin/thrust ./bin/rails server, Thruster :80 → Puma :3000), and the request path is Cloudflare (TLS) → cloudflared tunnel → kamal-proxy → the app container. Prod cut over from Phusion Passenger/nginx to Kamal on 2026-06-07 (bin/deploy is the Kamal wrapper; see doc/tasks/202606022303_KAMAL_MIGRATION.md).

Env vars are injected by Kamal from config/deploy.yml: non-secret values in each role’s env.clear block, secrets in env.secret (resolved from 1Password via the Kamal 1Password adapter in .kamal/secrets + .kamal/secrets-common, and redacted in deploy logs). The dotenv gem isn’t loaded in prod (it’s not in the default bundle group), so .env* files on the server are not read by Rails — and there’s no passenger_env_var / nginx site config any more.

bin/db restore (default source = Databasus/R2; reads creds from 1Password) sources .env.wasabi directly with shell source only on the Wasabi fallback path (BACKUP_SOURCE=wasabi). Stand-alone, not Rails-mediated.

You only have to run direnv allow once per .envrc content change, not on every directory entry. After that, direnv automatically loads .envrc (and unloads it) every time you cd in/out. The shell hook is installed once into ~/.zshrc by bin/setup:

Terminal window
eval "$(direnv hook zsh)"

You’ll see direnv allow again only when:

  1. You pull a change that modifies .envrc (direnv detects the diff and marks it untrusted again — security feature).
  2. You create a new worktree that shares the file via git worktree add (the file is content-identical to main, but direnv tracks trust by absolute path, so each worktree path needs its own one-time allow).

bin/setup-worktree runs direnv allow for you on case (2), so a fresh worktree comes online without manual intervention.

Lefthook is the repository’s sole Git hook manager. The tracked lefthook.yml defines the hooks; script/install_lefthook installs small dispatchers in the clone’s shared Git hooks directory. The main checkout and all of its worktrees use the same dispatchers, while each hook reads the current checkout’s configuration.

The hooks provide fast, local feedback. They do not replace GitHub CI, which remains authoritative for full suites, coverage, security scans, application boot checks, and production asset builds.

Hook What it does
pre-commit Runs RuboCop, ESLint, and Stylelint only against staged, newly added lines. Existing findings elsewhere in a touched file do not block the commit.
pre-push Examines only files in commits being pushed. It runs pushed-line Reek, YARD on pushed app/ and lib/ Ruby files, directly related Minitest or JavaScript tests, bundle-audit when Gemfile.lock changes, and actionlint when a workflow changes. System tests and broad test selections are deferred to CI.
post-checkout Normally does nothing. When Git creates a fresh worktree, it runs bin/setup-worktree --skip-deps to establish symlinks, runtime directories, mise/direnv trust, and an isolated test-database identity without delaying worktree creation for dependency installation.

Pre-push caps conventionally related test selections at 12 files. A broader change prints a notice and leaves those tests to bin/ci and GitHub rather than turning every push into a full-suite run. See Linting & Code Quality Tools for the complete division of responsibility between local hooks, first-party CI, and CodeRabbit.

A new clone gets Lefthook through bin/setup. After updating an existing clone to a revision containing the current lefthook.yml, run:

Terminal window
mise install
script/install_lefthook
direnv allow
  • mise install installs any newly pinned local tools, including actionlint and ShellCheck.
  • script/install_lefthook installs or refreshes pre-commit, pre-push, and post-checkout. Run it once per clone from any fully bootstrapped checkout; the resulting hooks are shared by that clone’s worktrees.
  • direnv allow trusts the updated .envrc. Run it in each existing worktree when direnv reports that the file changed.

The first subsequent local Rails test prepares that checkout’s isolated test databases automatically:

Terminal window
mise exec -- bin/rails test

For a newly created worktree, run bin/setup-worktree after the lightweight post-checkout bootstrap to install its Ruby and JavaScript dependencies.

Git can skip the commit and push verification hooks for one command:

Terminal window
git commit --no-verify
git push --no-verify

Set LEFTHOOK=0 for Git operations without a --no-verify option, or as a common escape hatch for any Lefthook-managed operation:

Terminal window
LEFTHOOK=0 git checkout <branch>
LEFTHOOK=0 git worktree add <path> <branch>
LEFTHOOK=0 git commit <arguments>
LEFTHOOK=0 git push <arguments>

These are one-command bypasses; they do not uninstall the hooks. If post-checkout is skipped while creating a worktree, run bin/setup-worktree inside it manually. Prefer fixing or rerunning a failed hook: bypassing local checks only defers the same feedback to CI.

Gitignored files don’t follow git worktree add, so a fresh worktree starts with no .env* files at all. bin/setup-worktree symlinks them in from the main checkout (config/database.yml, config/master.key, .env, .env.mcp, .env.mcp.local, .env.production.local, .env.wasabi, .envrc, .envrc.local). Editing any of them in main flows through to every worktree — they’re symlinks, not copies.

Two files deliberately remain local to each checkout: .env.db.local selects an optional copy-on-write development clone, while .env.test.local always selects isolated test databases. bin/setup-worktree creates the test selector and prepares its databases; bin/wt-clean removes that database family before removing the worktree.

.envrc itself is committed, so direnv has something to load on first cd into a fresh worktree. But because .env.mcp is gitignored, .envrc can’t find it on the very first load — so .envrc auto-invokes bin/setup-worktree once if .env.mcp is missing. That symlinks the file in, then the next direnv reload actually loads it.

GUI-launched Claude Desktop: the claude-desktop wrapper

Section titled “GUI-launched Claude Desktop: the claude-desktop wrapper”

Apps launched from Spotlight / Dock / Finder (Zed, Cursor, Claude Desktop) get launchd’s minimal env, not your shell’s. Every ${VAR} in .mcp.json would resolve to empty for MCP child processes spawned by those apps — and project MCPs (postgres-production, ahrefs, heatwave-development, …) would silently fail.

For Claude Desktop, bin/setup sets up two entry points:

Terminal: symlinks bin/claude-desktop into ~/bin/. The wrapper:

  1. Sources .env.mcp and .env.mcp.local from the main checkout (the .local file wins, matching direnv’s behavior).
  2. execs /Applications/Claude.app/Contents/MacOS/Claude directly, bypassing LaunchServices, so Claude inherits the env and propagates it to every MCP child it spawns.

Dock: script/build-claude-dock-app.sh builds a thin Claude (Heatwave).app wrapper bundle in ~/Applications/ whose launcher execs ~/bin/claude-desktop. The icon is reused from /Applications/Claude.app, so the two Dock tiles look identical — the name “Claude (Heatwave)” in the tooltip / Launchpad is what tells them apart. bin/setup then pins it to the Dock via dockutil (Brewfile-managed).

Quit any already-running Claude.app first — macOS only allows one instance, and a Claude launched via the stock icon won’t have the env.

After a credential rotation, re-run bin/setup --refresh-mcp (regenerates .env.mcp from 1Password), then quit and relaunch Claude Desktop via claude-desktop to pick up the new values.

An earlier version of bin/setup installed a LaunchAgent at ~/Library/LaunchAgents/com.warmlyyours.mcp-env.plist that ran script/mcp_env_loader.sh at login to push .env.mcp into launchd’s session env. We replaced it because it raced with Claude Desktop’s auto-launch at login and left Claude with a stale env until the next manual restart. bin/setup now removes the old LaunchAgent on dev machines that still have it.

direnv still wins inside the project dir — when you cd in, it loads .env.mcp and .env.mcp.local for terminal sessions independent of the wrapper.

You want to add… Put it in…
A new dev-only Rails-readable secret (Cloudflare token, third-party API key) .env (in main checkout; symlinks propagate to worktrees)
A new MCP-server secret that everyone needs 1Password vault IT, then update script/setup_mcp_servers.sh and .env.mcp.example
A personal token only you need (e.g. your own GitHub PAT) .env.mcp.local
The 1Password deploy service-account token (headless op for deploys) .env.mcp.local as OP_SERVICE_ACCOUNT_TOKEN — auto-written by script/setup_mcp_servers.sh; or scope it to the deploy only via .kamal/.op-service-account-token (read just by bin/deploy)
A prod-DB connection string for laptop scripts .env.production.local
A new prod-runtime env var (read by Rails in production) config/deploy.yml — the relevant role’s env.clear (non-secret) or env.secret (resolved from 1Password via .kamal/secrets*), not .env.production.local — that file is local-only
  • bin/setup — first-time / refresh setup for the main checkout
  • bin/setup-worktree — bootstrap for a fresh git worktree add clone
  • bin/claude-desktop — env-preloading wrapper around Claude Desktop
  • script/build-claude-dock-app.sh — build the Claude (Heatwave).app Dock bundle
  • script/setup_mcp_servers.sh — populate .env.mcp from 1Password
  • LINTERS.md — local hook, CI, and CodeRabbit check ownership
  • AGENTS.md (#mcp-server-credentials) — agent-facing summary