DEV Community

Wilson Xu
Wilson Xu

Posted on

Never Deploy with Missing ENV Vars Again — Build a Validator CLI

Never Deploy with Missing ENV Vars Again — Build a Validator CLI

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:

  1. Example drift — A developer adds REDIS_URL to their local .env and to the code, but forgets to update .env.example. New team members clone the repo and get cryptic errors.

  2. Silent undefined access — JavaScript doesn't throw when you read process.env.NONEXISTENT. It returns undefined, which might not surface until a specific code path runs in production.

  3. 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.

  4. Type confusionPORT=3000 is a string. If your code does if (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', ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This gives us three categories:

  • Missing — Required by .env.example but absent from .env. These are critical failures.
  • Extra — Present in .env but not in .env.example. These are warnings; they suggest the example file needs updating.
  • Empty — Present in .env but 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}`));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Or add it as a git pre-commit hook:

#!/bin/sh
npx envcheck --strict
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key package.json fields:

{
  "name": "envcheck",
  "bin": { "envcheck": "./bin/cli.js" },
  "dependencies": { "dotenv": "^16.0.0" }
}
Enter fullscreen mode Exit fullscreen mode

After publishing to npm, anyone on the team can run:

npx envcheck --strict
Enter fullscreen mode Exit fullscreen mode

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.example comments (# 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 .env contains 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 (1)

Collapse
 
theoephraim profile image
Theo Ephraim

Or just use varlock.dev (free and open source)