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.
Quick reference
| 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. |
How they layer
Rails boot (development / test)
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 toconfig/application.rb:34)
This is why CLOUDFLARE_API_TOKEN, HUGGING_FACE_TOKEN, etc. just work in
Rails.env.development? without any explicit source step.
Shell / MCP servers (any tool)
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 runtime
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.
DB restore script
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.
direnv: how it actually works
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:
eval "$(direnv hook zsh)"
You'll see direnv allow again only when:
- You pull a change that modifies
.envrc(direnv detects the diff and
marks it untrusted again — security feature). - 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.
Local Git hooks (Lefthook)
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.
Installed hooks
| 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.
Install or refresh
A new clone gets Lefthook through bin/setup. After updating an existing
clone to a revision containing the current lefthook.yml, run:
mise install
script/install_lefthook
direnv allow
mise installinstalls any newly pinned local tools, includingactionlint
and ShellCheck.script/install_lefthookinstalls or refreshespre-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 allowtrusts 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:
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.
Bypass a hook once
Git can skip the commit and push verification hooks for one command:
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:
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.
Worktrees
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
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:
- Sources
.env.mcpand.env.mcp.localfrom the main checkout (the
.localfile wins, matching direnv's behavior). execs/Applications/Claude.app/Contents/MacOS/Claudedirectly,
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.
Previously: launchd env bridge (removed)
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.
What goes where (cheat sheet)
| 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 |
See also
bin/setup— first-time / refresh setup for the main checkoutbin/setup-worktree— bootstrap for a freshgit worktree addclonebin/claude-desktop— env-preloading wrapper around Claude Desktopscript/build-claude-dock-app.sh— build theClaude (Heatwave).appDock bundlescript/setup_mcp_servers.sh— populate.env.mcpfrom 1PasswordLINTERS.md— local hook, CI, and CodeRabbit check ownershipAGENTS.md(#mcp-server-credentials) — agent-facing summary