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.
  • jq and yq on 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.

  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
}

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 and ajv parsing. Set core.autocrlf=input and run sed -i 's/\r$//' .env in 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.

  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

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 :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.

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": {}
  }
}

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 .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"

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.

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"