Validating Environment Variables Against a JSON Schema
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.
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.
- Author
env-schema.jsonwith types, anenumfor closed sets, ranges for numbers, and arequiredarray. EnablecoerceTypesin the validator so"20"validates as an integer and"true"as a boolean, and turn onallErrorsso 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
}
- 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 asKEY: messageso 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);
- Import the loader at the very top of the entrypoint so validation runs before any service is constructed. Because
coerceTypesmutatesconfigin place, downstream code readsconfig.MAX_CONNECTIONSas 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.
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.
Prevention
- Keep
env-schema.jsonas the single source of truth and generate.env.examplefrom 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. - 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 firstup. Because the loader exits non-zero on any violation, a plain invocation is a complete gate. - Add a pre-commit hook that validates the committed
.env.exampleagainst 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.
Platform caveats
WSL2: A
.envauthored by a Windows editor carries\ron each value, soLOG_LEVEL=warn\rfails theenumcheck againstwarnfor reasons invisible on screen. Setcore.autocrlf=inputand 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): Theenv-validatecontainer reads.envthrough a virtualized filesystem; keep the mount:roand pass the file withenv_file:rather than relying on host shell inheritance, so the container validates the same bytes the app will receive. Apple Silicon (ARM64): Pinajv-cli@5in 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.