--- name: env-drift-check description: Compare the environment variables your code actually reads against what is declared and what is set where it deploys, catching the missing key that only fails in production and the silent fallback that hides it. Use before a deploy, when onboarding to a repo, after adding a config value, or when something works locally and not in production. --- # Env Drift Check ## Install Save this file as `~/.claude/skills/env-drift-check/SKILL.md`, or `.claude/skills/env-drift-check/SKILL.md` to scope it to one repo. Claude Code auto-discovers it. Invoke with `/env-drift-check` or by asking "am I missing any environment variables in production?". ## Why this exists "Works locally, breaks in production" is usually one missing environment variable, and it is usually found by a customer. Three things make it hard to see. `.env.example` drifts the moment someone adds a key without updating it. A variable read in one obscure file looks the same as one read everywhere. And worst, a `?? "default"` turns a missing key into wrong behaviour instead of a crash, so nothing errors and the feature is just quietly off: emails do not send, payments run against a test key, the rate limiter falls back to per-process and stops limiting. The last category is the dangerous one. A crash gets fixed in ten minutes. A silent fallback runs for a month. ## Step 1 — Find every variable the code actually reads The source of truth is the code, not the example file. ``` rg -o "process\.env\.([A-Z_][A-Z0-9_]*)" -r '$1' --no-filename | sort -u rg -o "process\.env\[['\"]([^'\"]+)['\"]\]" -r '$1' --no-filename | sort -u rg -o "(?:os\.environ(?:\.get)?[\[(]|getenv\()['\"]([A-Z_][A-Z0-9_]*)" -r '$1' --no-filename | sort -u rg -o "(?:Deno\.env\.get|import\.meta\.env)\.?\(?['\"]?([A-Z_][A-Z0-9_]*)" -r '$1' --no-filename | sort -u ``` Union them. This set is what your application genuinely requires. Note which are read in code that reaches the CLIENT (anything prefixed `NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`). Those are baked into the bundle at build time and are public forever. **A secret with a public prefix is already leaked**, and it is worth stopping the audit to say so. ## Step 2 — Compare against what is declared ``` cat .env.example .env.sample .env.template 2>/dev/null | grep -oE '^[A-Z_][A-Z0-9_]*' ``` Produce two lists, and treat them differently: - **Read but not declared.** A new contributor cannot run this project, and it is invisible until they try. Cheap to fix, so fix it. - **Declared but never read.** Usually dead config from a removed feature. Cutting it is a small kindness; leaving it means everyone keeps provisioning something nobody uses. ## Step 3 — Compare against what is actually SET where it deploys This is the step that catches production outages, and the one people skip. ``` vercel env ls production # or: fly secrets list / heroku config / gh variable list ``` Many CLIs will not print values, which is fine. You are checking for PRESENCE. Flag anything the code reads that is absent from the deploy target. Rank by what breaks: a payment key or database URL outranks a feature flag. **Do not stop at names.** If a value can be read locally, sanity-check its SHAPE rather than assuming: a URL that parses, a key with the expected prefix, a connection string carrying no parameter the driver rejects. A present-but-wrong value fails exactly like a missing one and takes far longer to find, because the name is right there in the list. ## Step 4 — Find the silent fallbacks The highest-value part of this audit. Find every place a missing variable becomes a default instead of an error: ``` rg -n "process\.env\.[A-Z_]+\s*(\|\||\?\?)" rg -n "process\.env\.[A-Z_]+\s*\|\|\s*['\"]" rg -n "os\.environ\.get\(['\"][A-Z_]+['\"]\s*," ``` For each, ask what actually happens when it is missing, then classify: - **Legitimate** — a genuine local-dev default (`PORT ?? 3000`). - **Silently degrading** — the feature turns off with no signal. Emails stop sending, analytics stop recording, a shared rate limiter reverts to in-memory. These need a startup warning at minimum. - **Dangerous** — a security or money path defaults to something permissive or to a test credential. These must fail loudly instead. For anything in the last two groups, prefer failing at startup over failing at 3am: ``` function required(name) { const v = process.env[name]; if (!v) throw new Error(`Missing required env var: ${name}`); return v; } ``` ## Step 5 — Check the environments actually match each other Compare production against preview and staging. A variable present in one and absent in another means preview deploys are testing a different application than the one you ship, and the difference is invisible until it matters. ## Step 6 — Report ``` MISSING IN PRODUCTION <- fix before deploying read at -> breaks DANGEROUS FALLBACKS defaults to -> SECRETS WITH PUBLIC PREFIXES <- baked into the client bundle, treat as leaked UNDECLARED (blocks new contributors) read at DEAD CONFIG declared, never read ``` Order the report by blast radius, not alphabetically. The first line should be the one that takes the site down. ## Rules - The code is the source of truth. `.env.example` is a claim, not evidence. - Check presence in the DEPLOY TARGET, not just the repo. That is where the outage lives. - Never print a secret's value. Names, shapes, and presence are enough, and a transcript is a place secrets escape. - A missing variable that crashes is a good outcome. Report the silent ones first. --- From [Toolbay](https://toolbay.ai/product/env-drift-check). Free to use, modify, and share. Keep this line and others can find it too.