Every team I've worked on has had this incident at least once.
Friday afternoon deploy. CI is green. You push to production. Five minutes later, someone's pinging you in Slack: the app is crashing on startup. You SSH in, check the logs, and there it is:
Error: DATABASE_URL is not defined
You forgot to add the new environment variable to production. The .env file on your machine has it. The .env.example in the repo doesn't. Nobody noticed during review.
Half an hour of your Friday afternoon gone.
The fix is always the same — add the variable, redeploy, move on. But the problem keeps coming back. Because there's no automated check. You're relying on code review and memory.
And it's not just missing variables. The opposite problem is just as real: .env files that accumulate junk. OLD_REDIS_URL from a migration you finished six months ago. LEGACY_API_KEY for a service you sunset last quarter. They sit there, silently, because nobody wants to delete something they're not 100% sure is unused.
I've fixed this manually too many times. So I built dotenv-scan to do it automatically.
What it does
dotenv-scan scans your codebase for every env variable your code actually uses, then compares that against what's in your .env and .env.example. One command, three answers.
npx dotenv-scan scan
dotenv-scan v1.0.0 · scanned 47 files in 284ms
❌ Missing (3)
DATABASE_URL src/db.ts:12, src/config.ts:8
JWT_SECRET src/auth/middleware.ts:4
STRIPE_API_KEY src/payments/stripe.ts:22
⚠️ Undocumented (2)
INTERNAL_API_KEY
DEBUG_MODE
🗑️ Unused (1)
OLD_REDIS_URL
✅ OK (8)
PORT, NODE_ENV, API_BASE_URL, ... (and 5 more)
────────────────────────────────────────
Run `dotenv-scan generate` to update .env.example
No config file. No setup. Just npx and go.
The three problems it catches
Missing — your code does process.env.DATABASE_URL but .env doesn't have it. This is the Friday deploy problem. dotenv-scan catches it before you push.
Unused — .env has OLD_REDIS_URL but no file in your codebase references it. Safe to delete. dotenv-scan tells you which ones.
Undocumented — .env has INTERNAL_API_KEY but .env.example doesn't mention it. The next developer to clone your repo has no idea this variable needs to exist. dotenv-scan flags it.
Plugging it into CI
The check command is designed for pipelines. It exits with code 1 if any variables are missing:
# GitHub Actions
- name: Check env variables
run: npx dotenv-scan check
That's the whole setup. Now your Friday deploy problem becomes a PR-time failure instead.
If you want to be strict — fail on unused and undocumented too:
dotenv-scan check --strict
Auto-generating .env.example
The part I actually use the most is generate. It writes (or updates) .env.example from your scan results:
dotenv-scan generate
It's idempotent. If a key already exists in .env.example, it's preserved. New variables found in the scan get added. Variables in .env.example that are no longer referenced in your code get flagged.
One command to keep your docs in sync with reality.
Multi-language support
It's not just JavaScript. The scanner understands:
| Language | Patterns |
|---|---|
| JavaScript / TypeScript |
process.env.VAR, process.env['VAR']
|
| Python |
os.environ['VAR'], os.getenv('VAR'), os.environ.get('VAR')
|
| Go | os.Getenv("VAR") |
| Ruby |
ENV['VAR'], ENV.fetch('VAR')
|
So if you have a monorepo with a Node.js API and a Python worker, one scan covers both.
The interesting part: detecting dynamic access
The hardest case to handle was process.env[dynamicKey].
Static analysis can't tell you which variable this reads. The key is computed at runtime — maybe it comes from a config file, maybe it's user input, maybe it's constructed from an enum. You can't enumerate it.
I made a deliberate call here: don't try to be clever. Instead, detect the pattern and warn the user:
⚠️ Dynamic access detected
process.env[key] src/config/loader.ts:34
Static analysis can't determine which variables are accessed here.
Make sure these variables are covered in your .env.example manually.
Silently ignoring it would be worse — you'd get a false "all clear" and miss variables. Failing hard would be too noisy for codebases that use this pattern intentionally. A warning with the exact location felt right.
Architecture in one diagram
CLI (Commander.js)
│
├── Scanner ── Walker (fast-glob) ── Extractors (per-language regex)
│ │
│ EnvRef[] (used variables)
│
├── Parser ── dotenv parser ── EnvDef[] (defined variables)
│
└── Analyzer ── compares used ↔ defined ↔ documented
│
AnalysisResult
│
┌────┴────┐
Reporter Generator
(text/json) (.env.example)
Each layer is independently testable. The scanner doesn't know about .env files. The analyzer doesn't know about file systems. 88 tests, zero mocks of the actual fs calls in integration tests — they run against real fixture files.
Zero runtime dependencies (almost)
Three runtime deps, all small:
-
chalk— terminal colors -
commander— CLI argument parsing -
fast-glob— file walking
That's it. No dotenv library — the parser is custom because I needed comment-preservation and multiline value support that dotenv packages tend to strip. No framework. Ships as a standalone CLI that you can npx without worrying about what it pulls in.
Getting started
# No install required
npx dotenv-scan scan
# Or install globally
npm install -g dotenv-scan
GitHub: https://github.com/flyingsquirrel0419/dotenv-scan
If dotenv-scan saves you from a bad deploy, a ⭐ on the repo goes a long way. It's also the best way to let me know it's useful so I keep working on it.
The thing I keep coming back to is how small the fix is relative to how painful the problem is. One npx command in CI, and the whole class of "missing env variable in production" goes away. I wish I'd built this years ago.
Happy to dig into any of the implementation details in the comments — the multi-language extractor design and the dynamic access detection decision both have interesting tradeoffs worth talking through.
Top comments (4)
use varlock.dev
Great
Nice :) like bro what's under the hood ,are we scanning all files for ENV using fast glob grapping with regex :)
Does it detect destructuring vars like
const { DB_URL } = process.envand Is it aware of
.env.production, .env.localFor .env.production / .env.local — currently you point it at a single
file via --dotenv .env.local.
But the parser already has a parseDotenvFiles() function built in that
reads multiple files and lets later ones override earlier ones (matching
how frameworks like Next.js merge .env → .env.local → .env.production).
The multi-file auto-discovery is on the v2.0 roadmap under
"Monorepo multi-.env support".
For now, you can run it multiple times or use the library API directly
with parseDotenvFiles().
I will fix it soon :>