Environment files (.env*)

This project has seven .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. Used to run RAILS_ENV=production mise exec -- bin/rails … against the prod DB from your laptop, or to source via set -a; source .env.production.local; set +a in a one-off 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/restore (Wasabi fallback only) Wasabi S3 credentials for the legacy fallback fetch in bin/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.
.envrc tracked direnv Top-level direnv config that sources .env.mcp and .env.mcp.local. Also auto-runs bin/setup-worktree once on a fresh worktree (when .env.mcp is missing).

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

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 then .env.mcp.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/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:

  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.

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.

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

  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.

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 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
  • AGENTS.md (#mcp-server-credentials) — agent-facing summary