Skip to content

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.

FileTracked?Loaded byPurpose
.envgitignoredRails (Dotenv::Rails, dev/test only)App-level dev secrets — Cloudflare/HF/Vultr API tokens, etc. Auto-loaded into ENV on Rails boot.
.env.localgitignoredRails (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.localgitignoredHand-sourced into a shell to run prod-mode commands locallyProd 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.wasabigitignoredbin/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.mcpgitignoreddirenv (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.exampletrackedDocumentation/template for .env.mcp. Lists every variable with a comment pointing at its 1Password source.
.env.mcp.localgitignoreddirenv (via .envrc, loaded after .env.mcp so it wins on conflict); also sourced by bin/claude-desktopPer-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.exampletrackedTemplate for .env.mcp.local.
.envrctrackeddirenvTop-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).

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

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

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