Eliminating environment drift requires secret management that mirrors production behavior without exposing sensitive material. This guide gives platform engineers a tactical path to provision, inject, rotate, and validate local secrets, working within the environment sync and CI parity baseline. If you are weighing tooling first, compare the options in Vault vs dotenv-vault vs SOPS for local secrets; if rotation is forcing container restarts, see rotating secrets without restarting containers.

Prerequisites

  • Docker Engine 24+ with the Compose v2 plugin.
  • The vault CLI and jq on PATH.
  • direnv installed for shell-scoped credential injection.

Provision a Local Vault Instance

A lightweight HashiCorp Vault dev server gives you a single source of truth for development credentials, bound to the loopback interface.

  1. Run Vault in dev mode via Compose, mapped to 127.0.0.1:8200.
  2. Bootstrap the root token and enable the KV v2 engine for versioned audit trails.
  3. Keep VAULT_ADDR consistent everywhere it appears.
# docker-compose.yml
services:
  vault:
    image: hashicorp/vault:1.15
    ports:
      - "8200:8200"
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: "local-dev-token"
      VAULT_ADDR: "http://127.0.0.1:8200"
    cap_add:
      - IPC_LOCK
    volumes:
      - ./vault-data:/vault/file
    networks:
      - vault-net

networks:
  vault-net:
    driver: bridge

Drift check — validate init state and VAULT_ADDR consistency:

#!/usr/bin/env bash
set -euo pipefail

vault status -format=json | jq '.initialized'   # expect: true
grep -rn "VAULT_ADDR" .env* docker-compose.yml || true
echo "Vault provisioning check complete"

WSL2: The NAT layer can intercept 127.0.0.1. If host resolution fails, bind to 0.0.0.0 in the container but restrict access via Docker network policies. macOS (Docker Desktop): Raise allocated memory to 4GB+ to avoid OOM kills during KV v2 init. Apple Silicon (ARM64): Use the hashicorp/vault:1.15 multi-arch manifest. If the host kernel rejects IPC_LOCK, remove cap_add and rely on Docker's default memory locking.

Inject Dynamic Secrets into Dev Containers

Static .env files cause drift and exposure. Mount the Vault CLI and token into the workspace and fetch short-lived credentials on directory entry with direnv. The static-fallback resolution rules are in dotenv and configuration management.

// .devcontainer/devcontainer.json
{
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "remoteEnv": {
    "DB_PASSWORD": "${localEnv:VAULT_DB_PASS}"
  },
  "postCreateCommand": "command -v vault && vault kv get -field=password secret/dev/db > /tmp/.db_pass",
  "features": {
    "ghcr.io/devcontainers/features/vault:1": {}
  }
}

Drift check — confirm injected variables resolve inside the container:

#!/usr/bin/env bash
set -euo pipefail

direnv export json | jq 'keys'
docker inspect "$(docker ps -q -f label=devcontainer.local_folder)" \
  --format='{{json .Config.Env}}' | jq .
echo "Injection check complete"

WSL2: Store .vault-token and .envrc inside native ext4 (/home/user/...); /mnt/c I/O latency causes direnv polling delays. macOS (Docker Desktop): Volume propagation can run postCreateCommand before the CLI is mounted — gate it with command -v vault as above.

Renew Leases on a Background Daemon

Local development needs predictable credential lifecycles. Restrict the dev role to read and renew, then run a renewal loop with exponential backoff and a fallback that prevents workspace lockout. Keep decrypted material off disk per managing local secrets without committing to git.

#!/usr/bin/env bash
# scripts/rotate-local-secrets.sh
set -euo pipefail

LEASE_PATH="secret/dev/api-key"
BACKOFF=10
MAX_BACKOFF=300

while true; do
  if ! vault lease renew -increment=3600 "${LEASE_PATH}" 2>/dev/null; then
    echo "Lease expired or unreachable; refreshing via kv get..."
    vault kv get -format=json "${LEASE_PATH}" | jq -r '.data.data.value' > .env.local
    BACKOFF=10
  fi
  sleep "${BACKOFF}"
  BACKOFF=$(( BACKOFF * 2 > MAX_BACKOFF ? MAX_BACKOFF : BACKOFF * 2 ))
done

Drift check — alert when TTL drops below the rotation threshold:

#!/usr/bin/env bash
set -euo pipefail

TTL="$(vault lease lookup secret/dev/api-key -format=json | jq '.data.ttl')"
if [ "${TTL}" -lt 300 ]; then
  echo "CRITICAL: lease TTL ${TTL}s below 300s threshold"
fi
echo "Lease TTL check complete"

WSL2: Background cron/systemd may not survive restarts. Use nohup with PID tracking, or trigger the script via Windows Task Scheduler on login. Apple Silicon (ARM64): Ensure the devcontainer base image matches the host architecture; mismatched binaries fail during postCreateCommand.

Validate Secret Parity Against CI

Secret parity must be enforced programmatically. Export the local KV tree, normalize casing, and diff it against the CI-managed manifest. Wire the result into CI/CD pipeline parity checks so merges block on mismatch.

# Makefile
.PHONY: validate-secrets-parity
validate-secrets-parity:
	@vault kv list -format=yaml secret/dev/ | yq eval '.[]' - | sort > /tmp/local_keys.txt
	@curl -s "$(CI_SECRET_MANIFEST_URL)" | yq eval '.required_keys[]' - | sort > /tmp/ci_keys.txt
	@diff -u /tmp/ci_keys.txt /tmp/local_keys.txt && echo "PARITY OK" || (echo "DRIFT DETECTED"; exit 1)

Drift check — run the parity target and act on the exit code:

#!/usr/bin/env bash
set -euo pipefail

make validate-secrets-parity
echo "Secret parity verified"

WSL2: CRLF in Makefile targets breaks execution. Run dos2unix Makefile or set core.autocrlf=input. Apple Silicon (ARM64): Run yq via docker run --rm -v "$(pwd):/work" mikefarah/yq for consistent cross-platform behavior.

Rollback / recovery

If the renewal daemon corrupts .env.local or a lease is revoked mid-session, stop the daemon and re-fetch from Vault:

#!/usr/bin/env bash
set -euo pipefail

pkill -f rotate-local-secrets.sh || true
vault kv get -format=json secret/dev/api-key | jq -r '.data.data.value' > .env.local
echo "Local secret restored from Vault"