Environment variables are the backbone of modern application configuration. Database URLs, API keys, feature flags, port numbers — they all live in .env files during development and in your cloud provider's secret manager in production. But there's a silent killer lurking in every deployment pipeline: missing or misconfigured environment variables.
You've been there. A deploy goes out on Friday afternoon. The app crashes. Logs show TypeError: Cannot read properties of undefined. Someone added STRIPE_WEBHOOK_SECRET to the code two weeks ago but never told anyone to set it. The .env.example file? Outdated by three sprints.
In this article, we'll build a lightweight CLI tool that validates your .env files against .env.example, catches missing variables before they hit production, and integrates cleanly into CI/CD pipelines.
The .env Problem
The .env pattern, popularized by the twelve-factor app methodology, keeps configuration out of code. The companion .env.example file is supposed to serve as a living contract: "these are the variables this application needs."
In practice, the contract breaks down in predictable ways:
Example drift — A developer adds
REDIS_URLto their local.envand to the code, but forgets to update.env.example. New team members clone the repo and get cryptic errors.Silent undefined access — JavaScript doesn't throw when you read
process.env.NONEXISTENT. It returnsundefined, which might not surface until a specific code path runs in production.Staging/production mismatch — Your staging environment has 12 variables set. Production has 11. The missing one controls a payment provider. You find out when a customer complains.
Type confusion —
PORT=3000is a string. If your code doesif (process.env.ENABLE_CACHE), the string"false"is truthy. Surprise.
The fix isn't more discipline. It's automation. Let's build it.
Parsing .env Files with dotenv
The dotenv package is the standard way to load .env files in Node.js, but we don't want to load the variables into process.env — we just want to parse the file. Fortunately, dotenv exposes a parse method:
const fs = require("fs");
const dotenv = require("dotenv");
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf-8");
return dotenv.parse(content);
}
const env = parseEnvFile(".env");
// { DATABASE_URL: 'postgres://localhost/mydb', PORT: '3000', ... }
dotenv.parse() returns a plain object with key-value pairs. It handles comments, blank lines, quoted values, and multiline strings. We get structured data without side effects.
For .env.example, the values are typically empty or placeholder-filled, but the keys are what matter:
# .env.example
DATABASE_URL=
PORT=3000
REDIS_URL=
STRIPE_SECRET_KEY=sk_test_xxxx
ENABLE_CACHE=true
We parse both files, then compare.
Comparing .env Against .env.example
The core validation logic is surprisingly simple:
function validateEnv(envPath = ".env", examplePath = ".env.example") {
const env = parseEnvFile(envPath);
const example = parseEnvFile(examplePath);
if (!example) {
return { valid: false, error: "No .env.example found" };
}
if (!env) {
return { valid: false, error: "No .env file found" };
}
const requiredKeys = Object.keys(example);
const presentKeys = Object.keys(env);
const missing = requiredKeys.filter((key) => !(key in env));
const extra = presentKeys.filter((key) => !(key in example));
const empty = requiredKeys.filter(
(key) => key in env && env[key].trim() === ""
);
return {
valid: missing.length === 0,
missing,
extra,
empty,
summary: {
required: requiredKeys.length,
present: presentKeys.length,
missing: missing.length,
extra: extra.length,
empty: empty.length,
},
};
}
This gives us three categories:
-
Missing — Required by
.env.examplebut absent from.env. These are critical failures. -
Extra — Present in
.envbut not in.env.example. These are warnings; they suggest the example file needs updating. -
Empty — Present in
.envbut set to an empty string. Depending on your policy, these might be acceptable or not.
Let's wire it up to a CLI:
#!/usr/bin/env node
const result = validateEnv();
if (result.error) {
console.error(`Error: ${result.error}`);
process.exit(2);
}
console.log(`Required variables: ${result.summary.required}`);
console.log(`Present variables: ${result.summary.present}`);
if (result.missing.length > 0) {
console.log(`\nMissing (${result.missing.length}):`);
result.missing.forEach((k) => console.log(` - ${k}`));
}
if (result.extra.length > 0) {
console.log(`\nExtra (${result.extra.length}):`);
result.extra.forEach((k) => console.log(` - ${k}`));
}
if (result.empty.length > 0) {
console.log(`\nEmpty (${result.empty.length}):`);
result.empty.forEach((k) => console.log(` - ${k}`));
}
Running this in a project directory with a mismatched .env might output:
Required variables: 5
Present variables: 4
Missing (1):
- STRIPE_SECRET_KEY
Extra (0):
Empty (1):
- REDIS_URL
Immediately useful. But we can make it CI-friendly.
Strict Mode for CI/CD
In a CI/CD pipeline, pretty-printed output is nice but what really matters is the exit code. A non-zero exit code stops the pipeline, which is exactly what we want when environment variables are missing.
function run(options = {}) {
const { strict = false, failOnEmpty = false } = options;
const result = validateEnv();
if (result.error) {
console.error(`Error: ${result.error}`);
process.exit(2);
}
// ... print results ...
if (strict) {
const hasMissing = result.missing.length > 0;
const hasEmpty = failOnEmpty && result.empty.length > 0;
if (hasMissing || hasEmpty) {
console.error("\nValidation FAILED in strict mode.");
process.exit(1);
}
}
console.log("\nValidation passed.");
process.exit(0);
}
Now we add CLI flags using a lightweight argument parser:
const args = process.argv.slice(2);
const strict = args.includes("--strict");
const failOnEmpty = args.includes("--fail-on-empty");
run({ strict, failOnEmpty });
Integration into a GitHub Actions workflow becomes trivial:
# .github/workflows/ci.yml
jobs:
validate-env:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npx envcheck --strict --fail-on-empty
If any required variable is missing, the step fails and the pipeline stops. No more deploying broken configurations.
For Docker builds, you can validate at build time:
RUN npx envcheck --strict || (echo "ENV validation failed" && exit 1)
Or add it as a git pre-commit hook:
#!/bin/sh
npx envcheck --strict
The key insight: validation should run as early as possible in every workflow — local development, CI, and deployment.
JSON Output for Automation
Human-readable output is great for terminals, but automated systems need structured data. Adding a --json flag lets the tool integrate with monitoring dashboards, Slack bots, or custom deployment gates:
if (args.includes("--json")) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.valid ? 0 : 1);
}
The JSON output looks like this:
{
"valid": false,
"missing": ["STRIPE_SECRET_KEY"],
"extra": [],
"empty": ["REDIS_URL"],
"summary": {
"required": 5,
"present": 4,
"missing": 1,
"extra": 0,
"empty": 1
}
}
This opens up powerful workflows:
Slack alerts on missing vars:
RESULT=$(npx envcheck --json)
if echo "$RESULT" | jq -e '.missing | length > 0' > /dev/null; then
MISSING=$(echo "$RESULT" | jq -r '.missing | join(", ")')
curl -X POST "$SLACK_WEBHOOK" \
-d "{\"text\": \"Deployment blocked: missing env vars: $MISSING\"}"
fi
Multi-environment validation:
for ENV_FILE in .env.staging .env.production; do
echo "Checking $ENV_FILE..."
npx envcheck --env "$ENV_FILE" --json | jq '.summary'
done
Diff reporting in PRs:
Parse the JSON output in a GitHub Action, and post a comment on the PR whenever .env.example changes but the corresponding deployment configs haven't been updated.
Putting It All Together
The complete tool weighs in at about 80 lines of JavaScript. Here's the recommended project structure:
envcheck/
bin/cli.js # CLI entry point
lib/validator.js # Core validation logic
package.json # bin field points to cli.js
Key package.json fields:
{
"name": "envcheck",
"bin": { "envcheck": "./bin/cli.js" },
"dependencies": { "dotenv": "^16.0.0" }
}
After publishing to npm, anyone on the team can run:
npx envcheck --strict
No configuration files. No plugins. Just a comparison between what your app needs and what's actually set.
Beyond the Basics
Once you have the core validator, there are natural extensions worth considering:
-
Type validation — Define expected types in
.env.examplecomments (# PORT: number) and validate that values parse correctly. -
Required vs. optional — Use a prefix convention (
# OPTIONAL: ANALYTICS_KEY) to distinguish must-have from nice-to-have variables. -
Secret detection — Warn if
.envcontains values that look like real secrets and the file isn't in.gitignore. -
Multi-file support — Validate
.env.staging,.env.production, and other variants against the same example file.
Conclusion
Missing environment variables are a solved problem — if you validate early and automatically. A simple comparison between .env and .env.example catches the vast majority of configuration errors before they reach production.
The pattern is: parse, compare, fail loudly. Whether you build this yourself in 80 lines or use an existing tool, the important thing is that it runs in CI, not just on your laptop.
Stop trusting humans to remember environment variables. Let the machine check.
Found this useful? I build developer tools and write about CLI automation. Follow for more practical Node.js content.
Top comments (0)