obfus.link
Validators

Pre-deploy .env gate: diffing example vs local, type mismatches, secret detection

Validate any .env content against syntax rules or diff it against a canonical .env.example. Surfaces missing keys, extra keys, type mismatches, and credential-looking values before deploy. Catches config errors before the container crashes.

The Env Validator runs in validate mode (syntax + secret-flagging on a single file) or diff mode (comparing two .env files for missing keys, extra keys, and value drift). Secret values are masked in the diff output to keep CI logs safe. Catches misconfiguration before deploy rather than at runtime crash.

1. Insight

Insight

The problem this article addresses and why it matters.

The 2am deploy-crash that lives in your .env

A service that boots in production needs every environment variable its code reads. Miss one and the process either crashes immediately (good — fast feedback) or, worse, silently uses an undefined value that takes down a downstream service hours later. Every team has hit this. The fix is always the same: check the env against a schema before booting.

Most teams check the schema inside the application (a dotenv-safe call at startup, an env.ts file with Zod parsing). That's the right answer for runtime safety. It doesn't help with the question that should fire before deploy: does the production .env.local have everything the .env.example says it needs? Is the value of DATABASE_URL syntactically a connection string, or did someone paste the placeholder text? Is JWT_SECRET set to the literal string "changeme"?

These are pre-deploy checks. They live in CI, not in the running app — by the time the app is running, the bad env var has already crashed something.

Why a diff-mode validator beats a schema check

The tool in this article has two modes. Validate mode runs the typical schema checks (required keys, format validation, secret detection). Diff mode is the gate-worthy feature: pass envContent (the actual .env you're about to deploy) and referenceEnv (the canonical .env.example from the repo), get back a structured report of missing keys, extra keys, type mismatches, and changed values.

The diff is what makes the tool a CI gate. A missing DATABASE_URL is caught before the container starts, not after it crashes. A value that's still "changeme" is flagged before the secret gets exfiltrated. A new env var that the developer added to .env.example but forgot to update in the deployment platform's secret store is caught the moment the diff runs.

What this article delivers

End-to-end walks of both modes, with attention to the diff mode's structured output and the masking of secret values in the output. We cover the validation patterns (empty values on required-looking keys, unquoted spaces, duplicate keys, unexpanded variables) and the cases where the tool can't decide on its own (a value that might be a secret based on the key name but is actually a non-sensitive constant).

2. Intent

Intent

What you will be able to do after reading.

By the end of this article you will be able to:

  • Validate any .env content against syntax rules — duplicate keys, unquoted spaces, unexpanded variables, invalid lines
  • Run diff mode against a reference .env (typically .env.example) to find missing keys, extra keys, and changed values
  • Recognise the secret-detection patterns the tool flags and the cases where the flag is a false positive
  • Wire the diff mode into CI as a pre-deploy gate that fails the build on schema drift
  • Mask sensitive values in the diff output so the CI log doesn't expose credentials

The Examples section walks through a real .env.example vs .env.production diff, the secret-detection flagging, and the CI pattern that fails the build before the deploy.

3. Examples

Examples

Annotated code and worked scenarios.

Before / after: pre-deploy diff

You have .env.example checked into the repo and .env.production set up in your deployment platform. Are they in sync?

.env.example (canonical):

DATABASE_URL=postgres://user:pass@host/db
JWT_SECRET=changeme
STRIPE_SECRET_KEY=sk_test_...
LOG_LEVEL=info
FEATURE_NEW_BILLING=false

.env.production (the actual deployed value):

DATABASE_URL=postgres://prod_user:s3cr3t@db.prod.example.com/orders
JWT_SECRET=mC8z42a9bX1L4qPv7t9Y6N0kF3rA5dW2eS
LOG_LEVEL=info
SENTRY_DSN=https://abc@sentry.io/1

Diff:

envValidator({
  mode:         'diff',
  envContent:   productionEnv,
  referenceEnv: exampleEnv,
});

// valid: true
// diff: {
//   missingKeys: ['STRIPE_SECRET_KEY', 'FEATURE_NEW_BILLING'],
//   extraKeys:   ['SENTRY_DSN'],
//   changedValues: [
//     { key: 'DATABASE_URL', referenceVal: 'postgres://****', actualVal: 'postgres://****' },
//     { key: 'JWT_SECRET',   referenceVal: '****',           actualVal: '****' },
//   ],
//   typeMismatches: [],
// }
// secretFlags: [
//   { key: 'JWT_SECRET',       reason: 'key matches credential pattern (*SECRET)',        recommendation: 'Move to a secrets manager; do not commit' },
//   { key: 'STRIPE_SECRET_KEY', reason: 'key matches credential pattern (*KEY) + value matches Stripe key prefix', recommendation: 'Move to a secrets manager; do not commit' },
// ]

Three findings the CI pipeline cares about:

  1. STRIPE_SECRET_KEY and FEATURE_NEW_BILLING are in .env.example but missing in production. If STRIPE_SECRET_KEY is required at runtime, the deploy crashes the moment Stripe is called. Fail the build now.
  2. SENTRY_DSN is in production but not in the example. Either add it to .env.example (documentation) or remove it from production (probably someone forgot to update the canonical reference).
  3. Sensitive values are masked in the diff output — neither the actual production secret nor the example placeholder reaches the CI log. The masking happens automatically when the key matches the secret patterns.

Before / after: validation mode

Validate mode catches per-file syntax issues without needing a reference:

DATABASE_URL=postgres://user@host/db
API_KEY =
LOG_LEVEL=info debug
FEATURE_X=$UNDEFINED_VAR
DATABASE_URL=postgres://other  # duplicate
envValidator({
  mode:       'validate',
  envContent: env,
});

// valid: false
// keys: ['DATABASE_URL', 'API_KEY', 'LOG_LEVEL', 'FEATURE_X']
// errors: [
//   { key: 'API_KEY',     message: 'empty value on required-looking key', severity: 'warning' },
//   { key: 'LOG_LEVEL',   message: 'unquoted value contains a space — will be truncated at first space',  severity: 'critical' },
//   { key: 'FEATURE_X',   message: 'value contains unexpanded variable reference ($UNDEFINED_VAR)',       severity: 'warning' },
//   { key: 'DATABASE_URL', message: 'duplicate key — last definition wins (postgres://other)',             severity: 'warning' },
// ]
// secretFlags: [
//   { key: 'API_KEY', reason: 'key matches credential pattern (*KEY)', recommendation: 'Move to a secrets manager; do not commit' },
// ]

The LOG_LEVEL=info debug is the silent-failure case — most env parsers stop at the first space, so the runtime sees LOG_LEVEL=info and the debug is dropped silently. The validator catches this before deploy.

Before / after: CI integration

A GitHub Action that fails the build on a missing key:

- name: Validate env against canonical reference
  run: |
    EXAMPLE=$(cat .env.example)
    ACTUAL=$(cat .env.production)  # however the platform exposes it
    RESULT=$(curl -X POST https://obfus.link/api/v1/env_validator \
      -d "$(jq -n --arg ex "$EXAMPLE" --arg ac "$ACTUAL" \
        '{mode: "diff", envContent: $ac, referenceEnv: $ex}')")
    MISSING=$(echo "$RESULT" | jq -r '.diff.missingKeys | length')
    [ "$MISSING" -eq 0 ] || { echo "Missing env keys: $MISSING — failing build"; exit 1; }

Now every CI run validates the deployment env against the repo's canonical reference. A new env var added to .env.example that nobody set up in production fails the build before the deploy goes out.

When humans use this

A developer setting up a service on a new machine pastes .env.example and their working .env.local into the tool to see what's missing. A team auditing their secrets-hygiene runs the validator across every repo's .env.example and reads the secret-flags output to identify which credentials should move to a vault. A new joiner onboarding to a project uses the diff mode to align their local env with the team's canonical example file.

When agents use this

Three production patterns:

  • Deploy-gate agent. A CI agent runs the diff against the canonical reference on every PR that touches .env.example or environment configuration. Missing keys fail the build; extra keys open a "should this be documented?" comment.
  • Secrets-hygiene auditor. A scheduled agent runs validation mode across every .env.example in the org's repos, identifies keys that should move to a secrets manager, and opens advisory tickets sorted by severity.
  • Onboarding assistant. An agent setting up a developer's local environment compares the developer's local env against the canonical reference and walks them through filling the gaps. The diff mode's structured output drives the conversation deterministically.

Edge cases

Multi-line values

.env files don't have a portable multiline syntax. Some implementations support "line one\nline two" (with escapes), others wrap in triple quotes. The validator accepts both common variants; if your downstream parser expects a specific syntax, the validator's emit step preserves whatever encoding it saw.

${VAR} expansion

The validator detects $VAR and ${VAR} references and flags them as warnings — they're a pre-runtime construct that not every env loader supports. If you rely on expansion, document the loader (dotenvx, node --env-file with expansion, etc.) so future maintainers know.

Numeric vs string values

Env vars are always strings at the OS level — PORT=8080 is a string, not a number. The validator's type detection compares value-shape between the reference and actual (number-shaped vs boolean-shaped vs URL-shaped). Mismatches surface in typeMismatches; they're advisories, not errors.

Comments and blank lines

Lines starting with # and blank lines are ignored. Inline comments (KEY=value # comment) are non-standard — the validator parses them as part of the value. If your .env has inline comments, strip them before validating or accept the warning.

4. Documentation

Documentation

Reference signatures, edge cases, and lookup tables.

Input parameters

Field

Type

Required

Default

Description

mode

'validate' | 'diff'

Workflow selector

envContent

string

The .env content to validate or diff

referenceEnv

string

for diff mode

The canonical .env to compare against

Output shape

{
  valid:    boolean;
  keys:     string[];           // every key seen
  errors:   Array<{
    key:      string;
    message:  string;
    severity: 'critical' | 'warning';
  }>;
  secretFlags: Array<{
    key:            string;
    reason:         string;
    recommendation: string;
  }>;
  diff?: {                       // when mode: 'diff'
    missingKeys:    string[];    // in reference but not in envContent
    extraKeys:      string[];    // in envContent but not in reference
    changedValues:  Array<{
      key:           string;
      referenceVal:  string;     // masked if matches secret pattern
      actualVal:     string;
    }>;
    typeMismatches: Array<{
      key:           string;
      referenceType: 'number' | 'boolean' | 'url' | 'string';
      actualType:    'number' | 'boolean' | 'url' | 'string';
    }>;
  };
}

Validation checks

Check

Severity

When it fires

Empty value on credential-looking key

warning

Key matches *KEY, *SECRET, *TOKEN, *PASSWORD AND value is empty

Unquoted value with space

critical

Value contains a space without surrounding quotes — most parsers truncate at the space

Unexpanded variable reference

warning

Value contains $VAR or ${VAR} — depends on a loader that supports expansion

Duplicate key

warning

Same key appears twice (last definition wins by default; reader should confirm intent)

Invalid line format

critical

Line is neither blank, comment, nor a valid KEY=VALUE

Secret-flag patterns

Same patterns as yaml_to_env:

  • Key name matches *KEY, *SECRET, *TOKEN, *PASSWORD, *PRIVATE
  • Value matches known cloud-provider prefixes: sk_live_, sk_test_, pk_live_, AKIA*, xoxb-, xoxp-, ghp_, gho_
  • Value matches JWT shape (three base64 segments separated by .)
  • URL with embedded credentials (https://user:pass@host/...)

Error codes

Code

When it fires

Recovery

INPUT_EMPTY

envContent empty (or referenceEnv empty in diff mode)

Provide the required input

INPUT_MALFORMED

Lines don't parse as KEY=VALUE at all

Verify the input is a .env file, not JSON or YAML

INPUT_TOO_LARGE

Either input exceeds 200KB

.env files rarely exceed 50KB; pre-trim to relevant scope

When NOT to use this tool

For runtime env-var schema validation, use a Zod / Yup schema at application boot. The validator catches pre-deploy concerns; runtime validation catches at-startup concerns. The two are complementary.

For commit-history secret scanning (catching credentials that landed in git despite intentions), use trufflehog, gitleaks, or a similar tool. This validator works on a single env file; commit-history scanning is a different scope.

For multi-environment fan-out (one canonical env mapped to dev / staging / prod with environment-specific overrides), build that mapping in a config layer (Doppler, Vault, AWS Parameter Store). The validator can audit each rendered env file but doesn't manage the fan-out itself.

Performance notes

Typical execution: under 5ms for .env files under 50KB. Diff mode is O(n + m) where n and m are key counts. The tool is deterministic — same inputs always produce the same output — so REST responses are Edge-Cache eligible.

The secret-flag patterns track common credential shapes as of mid-2026. New cloud-provider key prefixes (a new SaaS launches with xxx_ style keys) require a tool release. Pin the version in CI workflows if your build needs exact reproducibility across releases.

Try it now

Env Validator

Audit .env files and diff against .env.example — the Pre-Deploy Gate

FAQ

Frequently asked questions

How does this differ from runtime env validation?

Runtime validation (Zod, joi at app boot) catches issues when the app starts. This tool catches them in CI before the app starts. They're complementary — runtime validation prevents bad envs from being used; the validator prevents bad envs from being deployed in the first place.

What does the secret scanner flag?

Keys matching *KEY, *SECRET, *TOKEN, *PASSWORD, *PRIVATE patterns. Values matching known credential prefixes (sk_live_, AKIA*, ghp_, xoxb-, JWT shape). URLs with embedded credentials (https://user:pass@host/...). Each flag has a severity rating and a recommendation pointing toward a secrets manager.

Can I run it as a GitHub Action?

Yes. The pre-deploy CI pattern is in the article — curl the tool with the actual .env content and the .env.example, parse the diff.missingKeys length, exit non-zero on any missing. The check fails the build before the deploy step runs.

Why are values masked in diff output even when they're identical?

The diff output is safe to log; the actual values are not. Both sides of a "changed" or "matching" comparison get masked when the key matches secret patterns. The diff is still useful (you know the values differ); the credential never reaches the log.