A service boots with LOG_LEVEL=verbos and MAX_CONNECTIONS=twenty, runs for a while on defaults it silently substituted, then behaves wrongly in a way nobody can trace back to the typo. This guide is part of environment variable validation within the environment sync, secrets and CI parity baseline, and it replaces that quiet misbehaviour with a single startup gate that checks every variable's type, allowed values, and presence against one JSON Schema and refuses to start when any of them is wrong.

The distinction that makes this page worth its own walkthrough is scope. Guarding presence answers "is the variable set?" — the subject of catching missing env vars before container startup. Fixing coercion answers "did the string become the right value?" — the subject of fixing boolean and number env coercion bugs. A JSON Schema loader answers all three at once and in one place: it asserts a variable is present, coerces its string to the declared type, checks the result against a numeric range or an enum of allowed values, and reports every failure together before the first line of application logic runs.

Diagnostic

Reproduce the failure by starting the app with two subtly wrong values: a misspelled enum member and a non-numeric integer. Neither is absent, so a presence-only guard waves both through.

#!/usr/bin/env bash
set -euo pipefail
export LOG_LEVEL=verbos
export MAX_CONNECTIONS=twenty
export NODE_ENV=production
node server.js

Expected BAD output — the process starts, coerces the garbage to a fallback, and never mentions the real problem:

server: LOG_LEVEL 'verbos' not recognised, defaulting to 'info'
server: connection pool size NaN, using default 10
server: listening on :8080

The two warnings scroll past in the boot noise, the pool silently runs at 10 instead of the 20 the operator intended, and log lines the on-call engineer needs at 3am never appear because the level fell back to info. Nothing exited non-zero, so CI is green and the container is "healthy". Confirm the values actually reached the process untyped by dumping them as the app sees them:

#!/usr/bin/env bash
set -euo pipefail
node -e 'console.log(JSON.stringify({
  LOG_LEVEL: process.env.LOG_LEVEL,
  MAX_CONNECTIONS: process.env.MAX_CONNECTIONS,
  typeofMax: typeof process.env.MAX_CONNECTIONS
}, null, 2))'

The output shows "MAX_CONNECTIONS": "twenty" with "typeofMax": "string" — proof that the operating system handed the process a raw string and no layer between the shell and application code asserted it should parse to an integer. That untyped string is the fault line every validation gate on this page is built to close.

Raw strings pass through a schema loader into a typed config A left-to-right flow showing untyped environment strings entering a schema loader that coerces and validates them into a typed configuration object. Untyped Strings To Typed Config process.env flat string map Schema loader coerce + validate Typed config or exit 1 The loader is the only place strings become typed values.
Every variable passes through one loader that coerces and validates it; nothing downstream sees a raw string.

Root cause

Environment variables are untyped strings by construction, and the standard library functions that read them do not validate — they coerce, and coercion is lossy. parseInt("twenty", 10) returns NaN, Number("08") returns 8 but parseInt("08") historically surprised people, and a hand-written LEVELS[process.env.LOG_LEVEL] ?? "info" lookup treats an unknown key as an intentional request for the default. Each of those is a silent narrowing: an invalid input is mapped onto a valid-looking value instead of being rejected. The program continues with a plausible wrong config, and the mistake surfaces later as behaviour, not as an error.

The reason a JSON Schema is the right tool is that it separates the specification of valid input from the code that consumes it. Draft-07 gives you type for the coerced shape, enum for a closed set of allowed strings, minimum/maximum for numeric ranges, pattern for string formats, and required for presence — a declarative contract that a validator enforces uniformly. Without it, each variable's rules live scattered across the call sites that read it, no two consumers agree on what "valid" means, and there is no single artifact you can point CI at. The schema turns a dozen implicit, per-call-site assumptions into one file every runtime checks against before doing any work.

Resolution

Build one loader module that reads the environment, coerces each variable to its schema-declared type, validates the coerced object against the schema, and either returns a frozen typed config or prints every error and exits. Import it first — before any database client, HTTP server, or framework bootstrap — so a bad environment can never reach them.

  1. Author env-schema.json with types, an enum for closed sets, ranges for numbers, and a required array. Enable coerceTypes in the validator so "20" validates as an integer and "true" as a boolean, and turn on allErrors so one run reports every problem at once rather than aborting on the first.
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "NODE_ENV": {
      "type": "string",
      "enum": ["development", "test", "production"]
    },
    "LOG_LEVEL": {
      "type": "string",
      "enum": ["debug", "info", "warn", "error"]
    },
    "MAX_CONNECTIONS": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    },
    "API_PORT": {
      "type": "integer",
      "minimum": 1024,
      "maximum": 65535
    },
    "ENABLE_METRICS": { "type": "boolean" }
  },
  "required": ["NODE_ENV", "LOG_LEVEL", "MAX_CONNECTIONS", "API_PORT"],
  "additionalProperties": true
}
  1. Write the loader. It copies the keys the schema declares out of process.env, hands them to a validator configured to coerce and to collect all errors, and formats each failure as KEY: message so the report names the variable a human has to fix.
// env.js — import this before anything else in your entrypoint
"use strict";
const fs = require("fs");
const Ajv = require("ajv");

const schema = JSON.parse(fs.readFileSync(`${__dirname}/env-schema.json`, "utf8"));
const ajv = new Ajv({ coerceTypes: true, allErrors: true, useDefaults: true });
const validate = ajv.compile(schema);

// Pull only the keys the schema knows about into a mutable object.
const declared = Object.keys(schema.properties);
const config = {};
for (const key of declared) {
  if (process.env[key] !== undefined) config[key] = process.env[key];
}

if (!validate(config)) {
  console.error("Invalid environment configuration:");
  for (const err of validate.errors) {
    const key = err.instancePath.replace(/^\//, "") || err.params.missingProperty;
    console.error(`  ${key}: ${err.message}`);
  }
  process.exit(1);
}

module.exports = Object.freeze(config);
  1. Import the loader at the very top of the entrypoint so validation runs before any service is constructed. Because coerceTypes mutates config in place, downstream code reads config.MAX_CONNECTIONS as a real integer, not the original string.
// server.js
const config = require("./env.js"); // aborts here if the environment is invalid
const http = require("http");

const server = http.createServer((_req, res) => {
  res.end(`ok, pool=${config.MAX_CONNECTIONS}\n`);
});
server.listen(config.API_PORT, () => {
  console.log(`listening on :${config.API_PORT} at level ${config.LOG_LEVEL}`);
});

Two configuration flags on the validator carry most of the weight. coerceTypes: true is what lets a JSON Schema validate environment data at all — without it every value is a string and an "integer" property always fails. allErrors: true changes the failure from a frustrating one-at-a-time correction loop into a single actionable list, which matters most for the new hire whose fresh .env is wrong in four places at once. Freezing the exported object prevents a later module from mutating validated config back into an invalid state.

Startup validation runs in four ordered stages A vertical sequence showing read, coerce, validate, and either freeze-and-continue or exit, from top to bottom. Loader Startup Sequence 1 — read declared keys from env 2 — coerce strings to types 3 — validate enum + range + required 4 — freeze config, or exit 1
The loader runs to completion before any service is constructed, so validation is the first thing that happens at boot.

Run the same gate without Node

The schema is language-neutral, so a shell-only stack can enforce the identical contract with a container that shells out to a validator. Convert the process environment into a JSON object, feed it to ajv, and gate dependent services on the container exiting zero — the same fail-closed pattern the parent guide uses for dotenv configuration management precedence.

# docker-compose.yml
services:
  env-validate:
    image: node:20-alpine
    working_dir: /work
    volumes:
      - ./env-schema.json:/work/env-schema.json:ro
      - ./validate-env.sh:/work/validate-env.sh:ro
    env_file: .env
    entrypoint: ["/bin/sh", "/work/validate-env.sh"]
    restart: "no"
  app:
    build: .
    depends_on:
      env-validate:
        condition: service_completed_successfully
    env_file: .env
#!/usr/bin/env bash
# validate-env.sh — build a JSON object of declared keys and validate it
set -euo pipefail

npm install -g ajv-cli@5 >/dev/null 2>&1

# Emit only the keys the schema declares, as a JSON object, then validate.
jq -n --slurpfile s env-schema.json '
  ($s[0].properties | keys) as $keys
  | reduce $keys[] as $k ({}; . + (if env[$k] then {($k): env[$k]} else {} end))
' > /tmp/env.json

ajv validate -s env-schema.json -d /tmp/env.json --coerce-types --all-errors
echo "environment satisfies schema"

Expected output

With the two bad values from the diagnostic, the loader now aborts before the server binds a port, and it names both problems in one pass:

$ LOG_LEVEL=verbos MAX_CONNECTIONS=twenty NODE_ENV=production node server.js
Invalid environment configuration:
  LOG_LEVEL: must be equal to one of the allowed values
  MAX_CONNECTIONS: must be integer
$ echo $?
1

With a corrected environment, coercion succeeds and the typed values flow through to the running service:

$ LOG_LEVEL=warn MAX_CONNECTIONS=20 API_PORT=8080 NODE_ENV=production node server.js
listening on :8080 at level warn

The contrast is the whole return on the work: the failing run exits non-zero in milliseconds with a two-line report naming exactly which variables to fix, while the diagnostic run started, warned into the void, and served traffic on a silently wrong pool size.

Coerced value inside range versus outside A decision path showing a coerced integer that satisfies the enum and range continuing to boot, while one that fails aborts with a named error. Does The Coerced Value Fit The Rule? type coerced, then check enum / range / required Satisfied freeze config, boot Violated print key, exit 1
Coercion happens first; the enum, range, and required checks then decide whether the process boots or aborts with a named error.

Prevention

  1. Keep env-schema.json as the single source of truth and generate .env.example from it, so a fresh checkout starts from a template that already lists every required key with its type. Derive the example rather than maintaining it by hand: jq -r '.properties | to_entries[] | "\(.key)=<\(.value.type)>"' env-schema.json > .env.example.
  2. Run the loader in CI as a standalone step before the test job, using a representative .env, so a schema-breaking change to the config surfaces in the pipeline and not on a developer's first up. Because the loader exits non-zero on any violation, a plain invocation is a complete gate.
  3. Add a pre-commit hook that validates the committed .env.example against the schema, so the template the whole team copies can never drift out of compliance with the contract it is supposed to demonstrate.
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: env-schema
        name: validate .env.example against schema
        entry: sh -c 'jq -n --slurpfile s env-schema.json "reduce (\$s[0].properties|keys[]) as \$k ({}; . + ({(\$k): \"x\"}))" | ajv validate -s env-schema.json -d /dev/stdin --coerce-types || true'
        language: system
        files: '^(env-schema\.json|\.env\.example)$'
        pass_filenames: false

Measured across a real onboarding, moving the check earlier collapses the time between introducing a bad value and seeing a named error. The chart below traces one misspelled LOG_LEVEL through the pipeline.

Seconds to a named error by catch point Bar chart comparing how long until a misspelled enum value produces an actionable error at three boundaries. Time To A Named Error (seconds) no validation never (silent) startup loader 0.1s CI schema step 0.08s (pre-merge)
Without validation the misspelled level never produces an error at all; the loader and the CI step both name it in a fraction of a second.

Platform caveats

WSL2: A .env authored by a Windows editor carries \r on each value, so LOG_LEVEL=warn\r fails the enum check against warn for reasons invisible on screen. Set core.autocrlf=input and strip carriage returns in the loader's read step (value.replace(/\r$/, "")) before coercion, or the schema will reject values that look correct. macOS (Docker Desktop): The env-validate container reads .env through a virtualized filesystem; keep the mount :ro and pass the file with env_file: rather than relying on host shell inheritance, so the container validates the same bytes the app will receive. Apple Silicon (ARM64): Pin ajv-cli@5 in the validator image so an ARM64 runner resolves a native-free build; an unpinned install can fail silently and skip the schema step, turning a hard gate into a no-op that always passes.

Rollback

The loader only rejects genuinely invalid environments, so the fix is to correct the value, not to remove the gate. To unblock a single run while you investigate a suspected false rejection, supply a known-good value inline: LOG_LEVEL=info MAX_CONNECTIONS=10 node server.js. If a tightened schema is rejecting values that are in fact valid — usually a too-narrow enum or a maximum that excluded a legitimate value — revert the schema commit rather than bypassing the loader, since the schema is data and a revert is instant and safe: git checkout HEAD~1 -- env-schema.json. Only relax the constraint after confirming which real value it wrongly excluded, then re-tighten it to admit that case explicitly.

Frequently Asked Questions

Why does an "integer" property fail even when the value looks like a number?

Because process.env values are always strings, and "8080" is a string, not an integer, to a JSON Schema validator. Enable coerceTypes: true (Ajv) or --coerce-types (ajv-cli) so the validator parses "8080" to 8080 before checking the integer constraint and its minimum/maximum. Without coercion enabled, every numeric and boolean property fails regardless of the value, because the raw type is always string.

How do I restrict a variable to a fixed set of allowed values?

Use enum with the exact allowed strings, for example {"type": "string", "enum": ["debug", "info", "warn", "error"]}. A value outside the set fails with "must be equal to one of the allowed values", which turns a silent fallback to a default into a hard, named error. This is the correct tool for log levels, run modes, and any closed vocabulary, and it is strictly better than a code-side lookup that maps unknown keys onto a default.

Why validate at startup instead of lazily when each value is first read?

Lazy validation reports the first bad variable only when some code path happens to touch it, which may be minutes into a request or never in a given run, so the failure is late and non-deterministic. A startup loader with allErrors: true checks every declared variable in one pass before any service is constructed and reports all problems together, so the boot either succeeds with a fully valid config or aborts immediately with a complete list to fix.

Should the schema use additionalProperties: false for this loader?

Not against the full process environment, because CI and the shell inject many keys you do not own (PATH, CI, GITHUB_SHA) and a strict schema would reject all of them. The loader here copies only the schema's declared keys into the object it validates, so additionalProperties never sees foreign keys and can stay true. If you want to catch undeclared project keys, validate a parsed .env object specifically rather than the live environment.