Environment Variable Validation
Enforce environment variable contracts with schema-driven validation. Stop config drift and silent CI breakages across local, container, and CI/CD runtimes.
Configuration drift is a primary vector for local failures, silent CI breakages, and slow onboarding. This guide builds a schema-driven validation pipeline that enforces strict environment variable contracts across local workstations, containerized runtimes, and CI systems. It implements the type-safety layer of the environment sync and CI parity baseline: treat .env files as version-controlled artifacts validated against a single schema. Two recurring failure modes get their own deep dives — catching missing env vars before container startup and fixing boolean and number env coercion bugs.
Prerequisites
- Node.js 18+ for
ajv-cli(npm i -g ajv-cli), or any JSON Schema validator. jqandyqon PATH for the drift and diff scripts.- Docker Engine 24+ with the Compose v2 plugin for the validation entrypoint.
Define a Strict Validation Schema
A canonical contract is the prerequisite for team alignment. Map every required, optional, and deprecated variable to a machine-readable definition before adding runtime guards.
- Declare explicit types, regex patterns for sensitive formats, and a strict
requiredarray. - Set
additionalProperties: falseso undeclared keys fail. - Run the validator in a pre-commit hook to block malformed configs before they reach Git.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"DATABASE_URL": {
"type": "string",
"format": "uri",
"pattern": "^postgres://.*"
},
"API_PORT": {
"type": "integer",
"minimum": 3000,
"maximum": 9000
},
"ENABLE_CACHE": { "type": "boolean" },
"DEPRECATED_TOKEN": { "type": "string", "deprecated": true }
},
"required": ["DATABASE_URL", "API_PORT"],
"additionalProperties": false
}
Drift check — flag undeclared or missing keys before runtime:
#!/usr/bin/env bash
set -euo pipefail
ajv validate -s env-schema.json -d .env --strict-types
diff <(jq -r '.properties | keys[]' env-schema.json | sort) \
<(grep -oP '^[^=]+' .env | sort) \
|| { echo "DRIFT: .env keys diverge from schema"; exit 1; }
echo "Schema parity OK"
WSL2: Windows editors append
\r\n, which breaks regex validators andajvparsing. Setcore.autocrlf=inputand runsed -i 's/\r$//' .envin the validation hook to normalize inputs.
Validate Before Dependent Services Start
In containerized stacks, validation must run before dependent services boot, or you get orphaned states from malformed credentials. The file-parsing hierarchy that decides which value wins is covered in dotenv and configuration management.
- Add a
validatorservice that runs the schema check and exits. - Make
appdepend on it withcondition: service_completed_successfully. - Mount the schema and
.envread-only.
# docker-compose.yml
services:
validator:
image: env-validator:latest
env_file: .env
volumes:
- ./env-schema.json:/schema.json:ro
- ./.env:/.env:ro
command: ["/bin/sh", "-c", "ajv validate -s /schema.json -d /.env"]
restart: "no"
app:
build: .
depends_on:
validator:
condition: service_completed_successfully
env_file: .env
Drift check — confirm the validator gate behaves as expected:
#!/usr/bin/env bash
set -euo pipefail
docker compose up --build -d
docker compose ps validator
# validator should show Exited (0); on failure inspect logs:
docker compose logs validator
echo "Validator gate exercised"
macOS / Windows (Docker Desktop): Volume mounts run through a virtualized Linux VM; keep
:rostrict to avoid permission mismatches. Apple Silicon (ARM64): Buildenv-validatormulti-arch (linux/amd64,linux/arm64) or append--platform linux/amd64for x86-only validation binaries.
Embed Validation in the Devcontainer Lifecycle
Devcontainers standardize local development but inherit host environment pollution. Run validation in the workspace init lifecycle so every container starts verified.
- Run
validate-env.shinpostCreateCommand. - Set a
remoteEnvmarker so failures are visible in the terminal. - Diff
remoteEnvkeys against the CI matrix nightly to catch IDE/CI divergence.
// .devcontainer/devcontainer.json
{
"name": "Validated Workspace",
"image": "mcr.microsoft.com/devcontainers/base:debian",
"postCreateCommand": "chmod +x ./scripts/validate-env.sh && ./scripts/validate-env.sh --schema ./env-schema.json --env .env",
"remoteEnv": {
"VALIDATION_MODE": "strict",
"NODE_ENV": "development"
},
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {}
}
}
Drift check — compare devcontainer keys against the CI matrix:
#!/usr/bin/env bash
set -euo pipefail
comm -23 \
<(jq -r '.remoteEnv | keys[]' .devcontainer/devcontainer.json | sort) \
<(yq '.matrix.env_vars[]' .github/workflows/ci.yml | sort) \
| grep -q . && echo "DRIFT: keys present locally but absent in CI" || echo "IDE/CI key parity OK"
WSL2: Keep
.devcontainerinside the Linux filesystem (~/projects), not/mnt/c. Cross-filesystem I/O can stallpostCreateCommandpast its timeout.
Enforce the Same Contract in CI
Local validation is insufficient without deterministic CI enforcement. A seed script bridges schema defaults, CI secrets, and runtime so injection order stays predictable on ephemeral runners. Tie rotated or expired credentials into this gate using the lifecycle hooks in local secret vaults and rotation.
#!/usr/bin/env bash
# scripts/seed-env.sh
set -euo pipefail
# Validate required keys against the live environment
for key in $(jq -r '.required[]' env-schema.json); do
if [ -z "${!key:-}" ]; then
echo "FAIL: missing required variable '${key}'"
exit 1
fi
done
echo "PASS: all required vars present"
Drift check — run the seed check before the test suite in CI:
#!/usr/bin/env bash
set -euo pipefail
bash scripts/seed-env.sh
echo "CI seed validation OK"
CI runners / ARM64: Runners switch between
ubuntu-latest(x86_64) andubuntu-22.04-arm. Use POSIX-safe syntax and installjq/ajv-clivia package managers (apt-get install jq) rather than prebuilt x86 binaries.
Rollback / recovery
If a schema change starts rejecting valid configs, revert the schema and regenerate the example file from it:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- env-schema.json
jq -r '.properties | to_entries[] | "\(.key)=<\(.value.type)>"' env-schema.json > .env.example
echo "Schema and example reverted"