Skip to content

SOPs

Env Files — `.env` and `.env.example`

Every repo that reads configuration uses two files:

  • .env — the real values. Gitignored. Lives on each machine. Never leaves the machine it was created on.
  • .env.example — the shape of .env with safe placeholders. Committed. The canonical reference for “what keys does this app need?”

No exceptions. Websites, CLIs, Chrome extensions, cron scripts, daemons, scrapers, agents — if it reads a config value, it reads it from .env, and its sibling .env.example is checked in.

  1. .env.example is the self-documenting contract. Anyone (future me, a fresh machine, a second Claude session) can clone the repo and know exactly which keys to fill in. No separate “setup notes” that go stale.
  2. .env never hits GitHub. That is the single most common way secrets leak. Gitignore is non-negotiable and the pattern enforces it by making the real file invisible to git by default.
  3. New machines bootstrap deterministically. cp .env.example .env && $EDITOR .env. That is the entire setup step on the Mac Studio or VPS after a git clone.
  4. Diffs stay clean. When a new env var is added, only .env.example changes in the commit. The values live outside git history forever.
  • One key per line. KEY=placeholder.
  • Placeholders describe the shape, never leak a real value. RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, not the actual key.
  • Group related keys with blank lines and a # Comment header above each group.
  • Mark optional keys with # optional at end of line.
  • Order keys by “must set to run” first, “nice to have” last.

Example:

# Resend — required for error alerts
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
RESEND_FROM=alerts@example.com
# Google OAuth — required for Gmail + GSC
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxx
# Debug toggles — optional
DEBUG=false # optional
LOG_LEVEL=info # optional, one of: debug|info|warn|error
  • Never committed. Add .env to .gitignore on the first commit of every repo.
  • Mode 600 on any shared machine: chmod 600 .env.
  • If a key is used in only one environment (local dev vs VPS), keep two files: .env on each machine with only the values it needs. Do not commit environment-specific variants.

Every .gitignore must include:

.env
.env.local
.env.*.local

The extra patterns catch framework-specific variants (Next.js uses .env.local, etc.). .env.example is NOT ignored — it must be committed.

  • Node / Next.js / Astro: Next and Astro load .env automatically. For raw Node scripts, use import 'dotenv/config' at the top of the entry file.
  • Python: from dotenv import load_dotenv; load_dotenv() at the top of the entry module. Package: python-dotenv.
  • Bash / cron scripts: set -a; source ./.env; set +a at the top. The set -a auto-exports every var so child processes see them.
  • Chrome extensions: see the Chrome extension caveat below.

Chrome extensions — build-time injection

Section titled “Chrome extensions — build-time injection”

Chrome extensions cannot read .env at runtime — the browser has no process.env. The .env / .env.example pattern still applies, but the values are baked into the extension at build time.

Two options:

  1. Config file generated by bump-build.sh (preferred). The build script reads .env and writes extension/config.js:

    Terminal window
    # in bump-build.sh, after the manifest rewrite
    python3 -c "
    import os
    from dotenv import load_dotenv
    load_dotenv('$REPO_DIR/.env')
    with open('$REPO_DIR/extension/config.js', 'w') as f:
    f.write('export const CONFIG = ' + repr(dict(os.environ)) + ';\n')
    "

    Then extension/config.js is gitignored (alongside .env). Extension code does import { CONFIG } from './config.js';.

  2. Hardcoded in the logger/background file if there is only one value (e.g., the localhost log receiver URL). Still put the value in .env.example as documentation even if the live code does not read it yet — future-me will thank present-me.

Either way: no secrets live in committed extension code. Treat extension/config.js exactly like .env — gitignored, regenerated on build.

The canonical source of truth for credentials that multiple apps reuse (Resend, Google OAuth, Claude API, etc.) is ~/apps/cc/shared-secrets.env. Individual app .env files either:

  • Reference it via source ~/apps/cc/shared-secrets.env at the top of the local .env (for bash-loaded apps), OR
  • Copy the specific key needed into the local .env (for apps where source-importing is awkward).

The shared vault itself is gitignored and lives only on machines that need it. tms-internal has a credentials map page (pointers only, never values) for finding things.

In order, at the start of every new app:

  1. Add .env to .gitignore before the first commit. Include the variant patterns above.
  2. Create .env.example with every key the app needs, safe placeholders, and grouped comments. Commit it in the first commit.
  3. Create .env locally by copying: cp .env.example .env. Fill in real values. This file stays local forever.
  4. Wire up the loader in the entry point (dotenv/config, load_dotenv(), or source).
  5. Verify the app fails loudly if a required key is missing. Do not silently fall back — crash with Missing env var FOO so the operator knows to fill .env.
  1. Add the key to .env.example with a placeholder and a comment explaining what it is.
  2. Add the real value to your local .env (and to ~/apps/cc/shared-secrets.env if it is shared).
  3. Propagate to every other machine that runs this app — Mac Studio, VPS, etc. The post-push-sync.sh script handles repo files, but .env is NOT synced (by design). Edit .env on each machine manually.
  4. Commit .env.example as Build X: add FOO env var for Y feature.
  5. Read the key in code using the app’s standard loader — no ad-hoc os.environ.get("FOO") buried in random files. Centralize in a config.py / config.js module.
  • .env
  • .env.local, .env.production, .env.anything.local
  • extension/config.js (Chrome extensions)
  • Anything with a real API key, password, or token in it

If one slips through, rotate the key immediately — git history is forever. Then purge the file with git filter-repo or bfg and force-push. Rotation first, purge second. Assume the key was leaked the second it hit the public commit, even if the repo is private.