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.

The core problem is that environment variables are untyped strings by construction. The operating system hands your process a flat map of KEY=value pairs with no notion of "this must be an integer between 3000 and 9000" or "this must match a postgres:// URI." Every layer that consumes those strings — your shell, dotenv configuration management, a container entrypoint, a CI runner — reinterprets them with its own coercion rules, and the disagreements only surface at runtime, often deep inside application code far from the misconfigured value. A schema turns that implicit, per-consumer contract into one explicit artifact that every runtime checks against before it does any work.

Why untyped variables cause drift

Drift is not a single event; it is the slow divergence of what each runtime believes the environment contains. A developer adds REDIS_URL to their local .env to unblock a feature, the CI job still injects only the original twelve keys, and the staging deploy reads a thirteenth from a secret store that nobody updated. Nothing errors at push time because no layer knows the full set of expected keys. The failure lands three environments later as a connection timeout, and the person debugging it has no map of what "correct" looks like.

A validation schema fixes the reference problem: it is the single source of truth for which keys exist, what shape each value takes, and which are mandatory. Once that artifact is checked into the repository, every runtime can answer the same question — "does this environment satisfy the contract?" — with a deterministic yes or no, and the answer is identical on a laptop, in a container, and on an ephemeral runner. The rest of this guide wires that check into each of those three places so a malformed value is rejected at the earliest possible boundary.

One schema validates three runtimes A central schema artifact feeds validation gates on the local workstation, the container stack, and the CI runner. One Schema, Three Gates env-schema.json single source of truth Local workstation pre-commit hook Container stack validator service CI runner seed-env gate
The same schema artifact drives validation at every boundary, so all three runtimes agree on what a valid environment is.

Prerequisites

  • Node.js 18+ for ajv-cli (npm i -g ajv-cli), or any JSON Schema validator.
  • jq and yq on PATH for the drift and diff scripts.
  • Docker Engine 24+ with the Compose v2 plugin for the validation entrypoint.
  • A repository where .env is git-ignored but a committed .env.example and env-schema.json live alongside the code, so the contract travels with the branch.

A note on tool choice: ajv-cli is used throughout because it implements JSON Schema draft-07 faithfully and reports the exact failing instance path, which matters when a hook has to tell a developer which variable is wrong. Any validator that emits a non-zero exit code on failure works — check-jsonschema, python -m jsonschema, or a hand-rolled Node script — but the exit-code contract is what the gates below depend on, so confirm your chosen tool returns non-zero on a validation failure before wiring it into a hook.

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.

  1. Declare explicit types, regex patterns for sensitive formats, and a strict required array.
  2. Set additionalProperties: false so undeclared keys fail.
  3. 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
}

Two design decisions make this schema load-bearing rather than decorative. First, additionalProperties: false is what catches drift in the additive direction — a developer who pastes a stray AWS_SECRET into their .env gets a hard failure instead of a silent, undocumented key that only their machine knows about. Without it, the schema validates a superset of the real contract and drift accumulates unchecked. Second, the pattern on DATABASE_URL encodes a semantic constraint the type system cannot: any string is a valid string, but only a postgres:// URI is a valid database URL for this stack. Push those semantic rules into the schema and the validator becomes a specification of intent, not just a shape checker.

The deprecated: true flag on DEPRECATED_TOKEN is a migration aid. Draft-07 validators do not fail on deprecated properties, so the key keeps working, but the annotation is machine-readable — a nightly job can grep the schema for deprecated keys, cross-reference which environments still set them, and open a cleanup ticket. This is how you retire a variable without a flag day: mark it deprecated, watch usage drain to zero across all three runtimes, then delete both the property and the value.

Bootstrapping the first schema by hand is tedious on an established project that already has forty variables. Generate a draft from the current .env and tighten it afterward: jq -Rn '{type:"object", properties:([inputs|select(length>0 and (startswith("#")|not))|split("=")[0]|{(.):{type:"string"}}]|add), additionalProperties:false}' < .env > env-schema.json emits a valid draft where every key is a required-nothing string. Then walk the file once, promoting integers, adding pattern constraints, and populating the required array. Starting from generated scaffolding rather than a blank file is the difference between a schema that ships this week and one that stays a backlog ticket, and because the generator is deterministic you can re-run it to spot keys added since the last review.

One subtlety trips up teams the first time: a raw .env file is not JSON, and ajv validates JSON. The drift script below sidesteps this by validating key presence with a diff and delegating value-shape checks to a parsing step that materializes the environment into a JSON object first. For strict per-value type checks against the schema, convert .env to JSON at the validation boundary — jq -Rn '[inputs | split("=") | {(.[0]): .[1]}] | add' < .env produces an object you can feed to ajv validate -d. Keep that conversion in one place so every runtime coerces identically; divergent parsers are themselves a source of drift, which is the whole subject of fixing boolean and number env coercion bugs.

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"

The diff compares two sorted key lists — the schema's declared properties and the actual keys in .env — and any asymmetry fails the check. This catches both directions of drift in one command: a key present in the schema but missing from .env (an unset required variable) and a key present in .env but absent from the schema (an undeclared addition). Wire this into .git/hooks/pre-commit or a pre-commit framework hook so the failure lands before the commit exists, not after it has propagated to a teammate.

WSL2: Windows editors append \r\n, which breaks regex validators and ajv parsing. Set core.autocrlf=input and run sed -i 's/\r$//' .env in the validation hook to normalize inputs.

Where a bad value gets caught A decision path showing that a malformed value is rejected at the schema gate before it reaches the application. Does The Value Satisfy The Schema? ajv validate type + pattern + required Valid services start Invalid exit 1, nothing boots
The gate is binary: a value either satisfies the contract and services proceed, or it fails and no dependent process starts.

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.

  1. Add a validator service that runs the schema check and exits.
  2. Make app depend on it with condition: service_completed_successfully.
  3. Mount the schema and .env read-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

The mechanism that makes this reliable is Compose's service_completed_successfully condition. Unlike a plain depends_on list — which only waits for the container to start, not to succeed — this condition blocks app until the validator container has exited with code 0. If ajv exits non-zero, the validator container ends in a failed state, the dependency condition is never satisfied, and app is never created. You get a fail-closed gate: a malformed environment produces an application that simply does not exist rather than one that half-starts and corrupts state. Setting restart: "no" is essential here; a restart policy would loop the validator and mask the failure as a perpetually-starting container.

This pattern also solves an ordering problem that plagues database-backed stacks. If the app boots, opens a connection pool with a malformed DATABASE_URL, and then crashes, you can leave half-open sockets, partial migrations, or lock rows that block the next start. Gating on a successful validator run means the app process never begins its startup sequence with bad inputs. For the specific case of a required variable being entirely absent — the most common trigger — the dedicated walkthrough on catching missing env vars before container startup shows how to make the validator's failure message name the missing key.

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"

To prove the gate actually fails closed, corrupt a value on purpose and confirm the app never comes up: API_PORT=99999 docker compose up should leave app uncreated and the validator logs pointing at the out-of-range integer. A gate you have never seen reject anything is a gate you cannot trust, so make this negative test part of the same script that verifies the happy path.

macOS / Windows (Docker Desktop): Volume mounts run through a virtualized Linux VM; keep :ro strict to avoid permission mismatches. Apple Silicon (ARM64): Build env-validator multi-arch (linux/amd64,linux/arm64) or append --platform linux/amd64 for x86-only validation binaries.

Container boot gated on validation Four ordered stages showing the validator running to completion before dependent services are created. Compose Startup Order 1 — validator reads schema + .env 2 — ajv exits 0 (or fails closed) 3 — condition met, app is created 4 — app opens connections safely
The app container is only created once the validator has exited successfully, so no service ever starts with an unverified environment.

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.

  1. Run validate-env.sh in postCreateCommand.
  2. Set a remoteEnv marker so failures are visible in the terminal.
  3. Diff remoteEnv keys 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": {}
  }
}

postCreateCommand runs once, after the container is built but before the editor attaches, which makes it the correct hook for a one-time contract check: a developer who opens the workspace with a broken .env sees the failure in the creation log immediately, not thirty minutes later when they run the app. If you need the check on every attach — for example when the .env is bind-mounted from a host that changes between sessions — move it to postStartCommand instead, at the cost of a small per-start latency. The distinction matters because a stale, cached container can otherwise carry a validated environment forward long after the underlying .env has drifted.

The remoteEnv block is doing double duty. VALIDATION_MODE: strict is a marker the validation script reads to decide whether a schema warning should be fatal, letting you keep the same script permissive in throwaway sandboxes and strict in the shared devcontainer. It also gives the nightly drift job a concrete set of editor-side keys to compare against the CI matrix, which is what the check below does. Devcontainer standards across a monorepo are their own topic — see best practices for devcontainer.json in monorepos for how to keep per-service configs from drifting apart.

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"

comm -23 prints lines unique to the first file — here, keys the devcontainer declares that CI does not. That asymmetric comparison is deliberate: a key that exists locally but not in CI is the dangerous case, because code written and tested against it in the editor will fail the moment it runs on a runner that never sets it. Run this as a scheduled workflow rather than on every push; drift between two config files accumulates on a scale of days, and a nightly signal is enough to catch it before it reaches a pull request.

WSL2: Keep .devcontainer inside the Linux filesystem (~/projects), not /mnt/c. Cross-filesystem I/O can stall postCreateCommand past 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"

This script reads the same env-schema.json the local hook and the container validator use, which is the entire point: three runtimes, one contract. The ${!key:-} indirect expansion looks up the value of the variable whose name is held in $key, so the loop checks the live process environment rather than a file — exactly what you want on a CI runner where secrets are injected as environment variables, not written to a .env. Because it iterates the schema's own required array, adding a mandatory variable to the schema automatically extends the CI gate with no code change; the schema stays the single edit point.

Order matters on ephemeral runners. Inject secrets first, then run seed-env.sh, then run the test suite — never interleave them. If the seed check runs before all secrets are injected it reports false failures; if it runs after the tests it is useless, because the tests already crashed on the missing value. The narrow window between "environment fully assembled" and "first test executes" is where this gate belongs. For a deeper treatment of keeping the runner and the laptop byte-for-byte equivalent, see how to gate merges on CI/local parity, which composes this seed check with image-digest and toolchain-version assertions.

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) and ubuntu-22.04-arm. Use POSIX-safe syntax and install jq/ajv-cli via package managers (apt-get install jq) rather than prebuilt x86 binaries.

Where validation pays off

The value of a schema gate is measured in how early it catches a bad value. The chart below traces a single malformed API_PORT through the pipeline: caught at the pre-commit hook it costs the author seconds; slipping to the container gate costs a failed local up; reaching CI costs a runner-minute and a red build; and escaping to staging costs a multi-person incident. Each boundary you add moves the average catch point left, and the cost of a miss falls by roughly an order of magnitude at every step.

Cost to fix a bad value by catch point Bar chart showing the relative minutes to remediate a malformed variable caught at four successive boundaries. Remediation Cost By Catch Point (minutes) pre-commit 0.2 container gate 2 CI seed check 12 staging incident 90+
Every gate you add earlier in the pipeline cuts remediation cost by roughly an order of magnitude; the pre-commit hook is the cheapest place to fail.

Platform caveats

Beyond the per-section notes above, a few cross-cutting issues affect the whole pipeline. Line endings are the most common: any .env authored on Windows carries \r characters that survive into the value, so ENABLE_CACHE=true\r fails a strict boolean check for reasons invisible in most editors. Normalize at the earliest gate and the rest of the pipeline stays clean.

WSL2: Run every validation script from inside the Linux filesystem. A .env edited by a Windows tool and validated by a Linux ajv will disagree on line endings and occasionally on file encoding (UTF-8 BOM). Strip both in the hook: sed -i '1s/^\xEF\xBB\xBF//; s/\r$//' .env. macOS (Docker Desktop): The validator container sees the .env through a virtualized filesystem with its own case sensitivity. Api_Port and API_PORT are distinct keys to the schema but may collide on a case-insensitive host mount; keep keys upper-snake-case and let additionalProperties: false reject any accidental variant. Apple Silicon (ARM64): If env-validator is an x86-only image it runs under emulation and adds seconds to every docker compose up. Build the validator multi-arch so the gate stays fast enough that developers never route around it.

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"

Regenerating .env.example from the reverted schema is the important half of this recovery: the example file is the artifact new developers copy to bootstrap their .env, so if it drifts from the schema every fresh checkout starts non-compliant. Deriving it from the schema with jq keeps the two in lockstep by construction. If a bad schema has already been merged and is failing everyone's pre-commit, the fastest unblock is to revert the schema commit on main and let each developer pull; the schema is data, so a revert is safe and instant. Only after the revert should you diagnose why the tightened constraint rejected a value that was in fact valid — usually a too-narrow pattern or a minimum that excluded a legitimate low port in a local override.

Frequently Asked Questions

Why validate .env keys with a schema instead of just letting the app crash on a missing value?

An app crash tells you something is wrong; a schema check tells you which key is wrong and why — wrong type, out of range, unknown property — before any process starts. It also fails closed at the earliest boundary (pre-commit), so a bad value never reaches a container or a runner. Relying on the app to crash also means every consumer re-implements its own ad-hoc check, which is exactly the per-consumer divergence that causes drift.

Does additionalProperties: false break when CI injects extra variables like CI or GITHUB_SHA?

It can, because a strict schema validated against the whole process environment will reject platform-injected keys. Validate against your project's .env object specifically — parse only the keys you own into a JSON object and feed that to ajv — rather than the entire environment. The CI seed check in this guide sidesteps the issue by iterating the schema's required array against live variables instead of validating the full environment against the schema.

Where in a Docker Compose stack should the validation run?

In a dedicated validator service that other services depend on with condition: service_completed_successfully. The validator reads the schema and .env, runs ajv, and exits. Because the dependency condition only resolves when the validator exits 0, a malformed environment prevents dependent services from ever being created — a fail-closed gate rather than an app that half-starts and corrupts state.

How do I retire a deprecated environment variable without breaking existing environments?

Mark it "deprecated": true in the schema. Draft-07 validators do not fail on deprecated properties, so the key keeps working while a nightly job greps the schema for deprecated keys and reports which environments still set them. Once usage drains to zero across local, container, and CI runtimes, delete both the schema property and the values. This retires the variable without a flag day.