# 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](https://12factor.net/config) 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 confusion** — `PORT=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:

```javascript
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:

```javascript
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.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:

```javascript
#!/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.

```javascript
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:

```javascript
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:

```yaml
# .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:

```dockerfile
RUN npx envcheck --strict || (echo "ENV validation failed" && exit 1)
```

Or add it as a git pre-commit hook:

```bash
#!/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:

```javascript
if (args.includes("--json")) {
  console.log(JSON.stringify(result, null, 2));
  process.exit(result.valid ? 0 : 1);
}
```

The JSON output looks like this:

```json
{
  "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:**
```bash
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:**
```bash
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:

```json
{
  "name": "envcheck",
  "bin": { "envcheck": "./bin/cli.js" },
  "dependencies": { "dotenv": "^16.0.0" }
}
```

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

```bash
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.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.*

