Dotenv & Configuration Management
Manage dotenv configuration across local, container, and CI environments. Enforce parity, automate secret injection, and eliminate the .env drift that breaks builds.
Standardizing local environment configuration is a control plane for platform engineering. Unmanaged .env drift between developer workstations, container runtimes, and CI/CD pipelines causes silent failures, security exposure, and slow onboarding. This guide gives you tactical workflows for enforcing configuration parity, injecting secrets safely, and making .env setup deterministic — all sitting under the broader environment sync and CI parity baseline. When several Compose files fight over the same key, jump to resolving env precedence conflicts across Compose files.
Prerequisites
- Docker Engine 24+ with the Compose v2 plugin.
- Git with
core.autocrlfconfigured per platform (see caveats below). diff,sha256sum, and optionallyhusky/lint-stagedfor commit-time hooks.
Version-Control a .env Template, Ignore the Rest
A deterministic configuration baseline starts with a tracked template and a strict ignore policy so real secrets never enter Git.
- Commit
.env.examplewith type hints; ignore every concrete.envvariant. - Block commits where
.envkeys drift from the template. - Wire the check into a pre-commit hook so drift is caught before it spreads.
# .env.example — annotated placeholders with type hints
# REQUIRED: application runtime mode (string: development|staging|production)
NODE_ENV=development
# REQUIRED: database connection string (postgresql://user:pass@host:port/db)
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_dev
# OPTIONAL: feature flags
ENABLE_TELEMETRY=false # boolean: true|false
LOG_LEVEL=info # string: debug|info|warn|error
# Track the template, ignore all concrete variants
!.env.example
.env
.env.*
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: env-drift-check
name: Validate .env key set against .env.example
entry: >-
bash -c 'diff <(grep -oE "^[A-Z_]+" .env.example | sort)
<(grep -oE "^[A-Z_]+" .env | sort) || (echo "ERROR: .env keys drift
from .env.example" && exit 1)'
language: system
pass_filenames: false
always_run: true
Drift check — fail a pipeline if the key sets diverge:
#!/usr/bin/env bash
set -euo pipefail
diff <(grep -oE '^[A-Z_]+' .env.example | sort) \
<(grep -oE '^[A-Z_]+' .env | sort) \
|| { echo "DRIFT: .env keys diverge from template"; exit 1; }
echo "Template parity OK"
WSL2: Windows
CRLFline endings corruptdiffandenvsubstparsing. Setgit config --global core.autocrlf inputand rundos2unix .env.examplebefore committing. macOS / Windows (Docker Desktop): Volume sync latency can return stale.envreads during rapid restarts. Set a stableCOMPOSE_PROJECT_NAMEand rundocker compose down -vbefore reinitializing.
Inject Configuration into Containers via Compose
Explicit environment mapping prevents host variable leakage and keeps container runtime deterministic.
- Layer
env_filefor shared values; useenvironmentonly for explicit overrides. - Keep secrets out of
environment— mount them with Composesecretsinstead. - Generate a no-interpolation config hash so CI can detect schema drift.
# docker-compose.yml
services:
app:
build: .
env_file:
- .env
- .env.local
environment:
# Explicit overrides take precedence over env_file
- NODE_ENV=production
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
# docker-compose.secrets.yml — file-backed secret instead of plaintext env
services:
app:
secrets:
- db_password
environment:
- DATABASE_URL=postgresql://postgres:@db:5432/app_prod
secrets:
db_password:
file: ./secrets/db_password.txt
Drift check — hash the resolved config without interpolating secrets:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --no-interpolate | sha256sum > .ci/env-baseline.sha256
git diff --exit-code .ci/env-baseline.sha256 \
|| echo "DRIFT: Compose schema changed since last baseline"
Apple Silicon (ARM64): Base images default to
linux/arm64. If an upstream image lacks a multi-arch build, declareplatform: linux/amd64in the service and enable Rosetta in Docker Desktop. WSL2: Use relativeenv_filepaths so absolute Windows-drive paths (/mnt/c,/host_mnt) do not break resolution when switching backends.
Replace plaintext .env injection with ephemeral credential mounts using the patterns in local secret vaults and rotation.
Bootstrap a Devcontainer with a Verified .env
A containerized editor toolchain removes "works on my machine" failures by shipping the same configuration to every developer.
- Bind
docker-compose.ymlplus the override into the devcontainer. - Seed
.envfrom the template inpostCreateCommandif it is missing. - Dump and diff the resolved environment to confirm consistency.
// .devcontainer/devcontainer.json
{
"name": "App Workspace",
"dockerComposeFile": ["../docker-compose.yml", "../docker-compose.override.yml"],
"service": "app",
"workspaceFolder": "/workspace",
"containerEnv": {
"NODE_ENV": "development",
"CI": "false"
},
"postCreateCommand": "test -f /workspace/.env || cp /workspace/.env.example /workspace/.env; npm ci",
"customizations": {
"vscode": {
"extensions": ["ms-azuretools.vscode-docker", "dbaeumer.vscode-eslint"],
"settings": {
"terminal.integrated.env.linux": { "NODE_ENV": "development" }
}
}
}
}
Drift check — confirm the resolved environment matches expectations after init:
#!/usr/bin/env bash
set -euo pipefail
devcontainer up --workspace-folder .
CONTAINER_ID="$(docker ps -q -f label=devcontainer.local_folder)"
docker exec "${CONTAINER_ID}" env \
| grep -E '^(NODE_ENV|DATABASE_URL)=' | sort > .ci/dev-env-dump.txt
diff -u .ci/expected-env.txt .ci/dev-env-dump.txt
echo "Devcontainer env parity OK"
WSL2: Mount the project inside the Linux filesystem (
~/projects) via the Remote-WSL extension. Mounting from/mnt/ctriggers 9P latency and file-watcher limits that stallpostCreateCommand. macOS (Docker Desktop): Hot-reload plus debug ports are RAM-hungry. Raise the memory limit to 8GB+ ifnpm cihangs during container init.
Confirm the devcontainer matches the CI runner with CI/CD pipeline parity checks.
Populate Host-Specific Values with a Seed Script
Static templates cannot capture host-specific topology (IPs, architecture, socket paths). A POSIX seed script fills that gap idempotently.
- Skip generation if
.envalready exists unless--forceis passed. - Inject dynamic host values into
.env.local, never the tracked template. - Syntax-check the script as a drift guard.
#!/usr/bin/env bash
# scripts/bootstrap-env.sh
set -euo pipefail
ENV_FILE=".env"
LOCAL_ENV=".env.local"
TEMPLATE=".env.example"
if [ -f "${ENV_FILE}" ] && [ "${1:-}" != "--force" ]; then
echo "${ENV_FILE} exists. Skipping. Pass --force to overwrite."
exit 0
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo '127.0.0.1')"
ARCH="$(uname -m)"
if [ -f "${TEMPLATE}" ]; then
cp "${TEMPLATE}" "${ENV_FILE}"
echo "Generated ${ENV_FILE} from template."
fi
cat <<EOF > "${LOCAL_ENV}"
DOCKER_HOST=${DOCKER_HOST:-unix:///var/run/docker.sock}
LOCAL_IP=${HOST_IP}
HOST_ARCH=${ARCH}
EOF
echo "Populated ${LOCAL_ENV} with dynamic host values."
Drift check — validate syntax without mutating the filesystem:
#!/usr/bin/env bash
set -euo pipefail
bash -n scripts/bootstrap-env.sh
echo "Seed script syntax OK"
macOS vs Linux: macOS ships a minimal
envsubstlacking--variables. Installgettextvia Homebrew or rely on native shell parameter expansion as above. Apple Silicon (ARM64):uname -mreturnsarm64on macOS andaarch64on Linux ARM. Route architecture-specific binaries with acasestatement on that value.
Rollback / recovery
If a generated .env corrupts local state, restore from the tracked template and re-derive host values:
#!/usr/bin/env bash
set -euo pipefail
cp .env.example .env
bash scripts/bootstrap-env.sh --force
echo "Configuration reset from template"