Detecting Unused and Undocumented Env Vars
Production sets PAYMENT_TIMEOUT_MS=8000, the code reads PAYMENTS_TIMEOUT_MS, and every payment call uses the 2-second default — a misspelling that has been silently degrading checkout for months. The Compose file still sets ELASTIC_URL for a search backend removed last year, CI injects eleven secrets of which four are unused, and a new hire asks what LEGACY_MODE=1 does and nobody knows. Configuration spreads across code, .env.example, Compose files, CI workflows and deployment manifests, and each copy drifts independently. This page cross-references them to find variables that are set but never read, read but never set, and near-miss names, as part of environment variable validation.
Unused variables are clutter and, for secrets, unnecessary exposure; undocumented and misspelled ones are silent bugs.
Diagnostic
Collect variable names from every place configuration lives and compare the sets:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /tmp/envaudit && cd /tmp/envaudit
root="$OLDPWD"
grep -rhoE 'process\.env\.[A-Z][A-Z0-9_]+|os\.environ(\.get)?\(?\[?["'"'"'][A-Z][A-Z0-9_]+' "$root/src" "$root/worker" 2>/dev/null | grep -oE '[A-Z][A-Z0-9_]+$' | sort -u > read.txt
grep -hoE '^[A-Z][A-Z0-9_]*=' "$root/.env.example" | tr -d '=' | sort -u > example.txt
(cd "$root" && docker compose config --format json) | jq -r '.services[].environment // {} | keys[]' | sort -u > compose.txt
grep -rhoE '\$\{\{ *secrets\.[A-Z0-9_]+|^\s+[A-Z][A-Z0-9_]+:' "$root/.github/workflows" | grep -oE '[A-Z][A-Z0-9_]+' | sort -u > ci.txt
echo "set in compose, never read:"; comm -13 read.txt compose.txt
echo "read in code, never documented:"; comm -23 read.txt example.txt
Expected bad output:
set in compose, never read:
ELASTIC_URL
LEGACY_MODE
PAYMENT_TIMEOUT_MS
read in code, never documented:
PAYMENTS_TIMEOUT_MS
SEARCH_PAGE_SIZE
PAYMENT_TIMEOUT_MS appears in the "never read" list and PAYMENTS_TIMEOUT_MS in the "never documented" list — the same setting under two spellings.
Root cause
A variable's name is written in at least two places — where it is read and where it is set — and often in five: code, example file, Compose, CI and deployment configuration. Nothing links them. Renaming a variable in code without renaming it everywhere else leaves the old name set and unused and the new name read and unset; with a default in the code, nothing fails, and the default silently wins. Removing a feature removes the read but rarely the settings, so dead variables accumulate, including secrets that no longer need to be distributed. And variables added to deployment manifests during an incident never make it back into .env.example, so they are undocumented for everyone else. The only way to see this is to compare the sets, which no single tool does by default.
Onboarding makes the cost concrete. A new hire reading the Compose file or the deployment values sees LEGACY_MODE=1 and reasonably assumes it matters, so they spend time asking what it does or, worse, copy it into a new service. Every dead variable is a small tax on everyone who reads the configuration, and the tax compounds as services multiply. Undocumented variables have the opposite effect: they are needed but invisible, so the only way to learn about them is to break something and ask. An audit that runs in CI keeps both lists empty, which keeps configuration files readable as documentation of what the system really uses.
Resolution
- Turn the diagnostic into a script that also reports near-miss names, using edit distance to flag likely misspellings:
import json
import pathlib
import re
import subprocess
import sys
from difflib import get_close_matches
root = pathlib.Path(".")
read = set()
for path in list(root.glob("src/**/*.ts")) + list(root.glob("worker/**/*.py")):
text = path.read_text(encoding="utf-8")
read |= set(re.findall(r"process\.env\.([A-Z][A-Z0-9_]+)", text))
read |= set(re.findall(r"os\.(?:environ(?:\.get)?\(?\[?|getenv\()[\"']([A-Z][A-Z0-9_]+)", text))
compose = json.loads(subprocess.check_output(["docker", "compose", "config", "--format", "json"]))
set_vars = {k for svc in compose["services"].values() for k in (svc.get("environment") or {})}
documented = {line.split("=", 1)[0] for line in pathlib.Path(".env.example").read_text().splitlines() if re.match(r"^[A-Z][A-Z0-9_]*=", line)}
problems = 0
for name in sorted(set_vars - read):
hint = get_close_matches(name, read, n=1, cutoff=0.85)
print(f"set but never read: {name}" + (f" (did you mean {hint[0]}?)" if hint else ""))
problems += 1
for name in sorted(read - documented):
print(f"read but undocumented: {name}")
problems += 1
sys.exit(1 if problems else 0)
Save as scripts/env_audit.py. get_close_matches catches PAYMENT_TIMEOUT_MS versus PAYMENTS_TIMEOUT_MS.
Fix each finding at its source: rename the misspelled setting everywhere, delete dead variables from Compose, CI and deployment configuration, and document the rest in
.env.example.Include deployment manifests in the audit, for example Helm values or Kubernetes manifests:
#!/usr/bin/env bash
set -euo pipefail
helm template charts/shop -f charts/shop/values-prod.yaml \
| yq -r 'select(.kind == "Deployment") | .spec.template.spec.containers[].env[]?.name' | sort -u > /tmp/envaudit/prod.txt
comm -13 /tmp/envaudit/read.txt /tmp/envaudit/prod.txt | sed 's/^/set in prod, never read: /'
- Remove unused secrets from secret stores once nothing reads them, reducing what has to be rotated and protected.
Expected output
$ python3 scripts/env_audit.py
set but never read: PAYMENT_TIMEOUT_MS (did you mean PAYMENTS_TIMEOUT_MS?)
set but never read: ELASTIC_URL
set but never read: LEGACY_MODE
read but undocumented: SEARCH_PAGE_SIZE
$ # after fixes
$ python3 scripts/env_audit.py && echo "configuration consistent"
configuration consistent
The audit names the misspelling with a suggestion, lists dead and undocumented variables, and passes once they are fixed. The checkout timeout now uses the intended 8 seconds in every environment.
The misspelling is the finding that justifies the whole exercise: it was not caught by tests, because tests run with defaults; not by code review, because the code and the manifest were changed months apart; and not by monitoring, because a two-second timeout failing some payments looks like a slow provider. Only comparing names across sources reveals it, and the same comparison in CI prevents the next one.
Prevention
Run the audit in CI on pull requests that touch code, Compose, workflows or deployment configuration, and fail on findings.
Validate configuration with a schema at startup, which catches read-but-unset variables immediately; combined with the audit, it covers both directions. See validating config at startup with zod and pydantic.
Reject unknown variables in the schema where the platform allows (for example
extra="forbid"in pydantic for a dedicated prefix), so a misspelled setting fails instead of being ignored.
Platform caveats
Dynamic reads: code that builds names at runtime, such as
process.env[`${prefix}_URL`], is invisible to static scanning; list such prefixes in the audit script explicitly, or avoid dynamic names.
Framework-injected variables: platforms inject variables such as
PORT,HOSTNAMEorKUBERNETES_SERVICE_HOST; keep an allowlist in the script so they are not reported as undocumented.
Windows: the Python audit runs natively; the Bash diagnostic needs WSL2 or Git Bash.
Rollback
The audit is read-only; remove the CI step if it blocks urgent work while findings are fixed:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .github/workflows/config-audit.yml
Frequently Asked Questions
How can a misspelled environment variable go unnoticed?
If the code reads the variable with a default, a misspelled setting is simply ignored and the default is used. Nothing errors, so only a comparison of set and read names — or a behaviour difference — reveals it.
Is it harmful to leave unused variables set?
Unused secrets are exposure without benefit: they are distributed, stored and must be rotated. Unused non-secret variables mislead people reading the configuration. Remove both.
Where should variables be documented?
In .env.example or a configuration schema that generates it, with a description per variable. Anything read by the code should appear there.
Can the audit find variables read by third-party libraries?
Only if they appear in your code. Libraries that read variables themselves, such as AWS_REGION, should be added to an allowlist with a comment explaining where they are consumed.