Dotenv & Configuration Management
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.
Configuration is not a static artifact. A single service reads its settings from a chain of sources — a tracked template, a developer's local overrides, a Compose environment block, shell exports inherited from the parent process, and finally the values a CI runner injects at pipeline time. Every one of those layers can disagree with the others, and the disagreements surface as bugs that only reproduce on one machine. The engineering goal is not to eliminate layers; layering is what lets a shared template coexist with host-specific values. The goal is to make the resolution order explicit, verifiable, and identical everywhere the code runs. The workflows below treat .env handling as a build input under the same rigor you apply to a lockfile: version-controlled, diffable, and gated in CI.
Throughout this guide the running example is a Node.js web service backed by PostgreSQL, orchestrated locally with Docker Compose and built in a CI pipeline. The techniques translate directly to Python, Go, or Ruby stacks — only the variable names change. What stays constant is the discipline: one canonical template, an ignore policy that makes leaking a secret require deliberate effort, and a drift-detection command wired into every boundary the configuration crosses.
Prerequisites
- Docker Engine 24+ with the Compose v2 plugin (
docker compose versionshould report v2.x). - Git with
core.autocrlfconfigured per platform (see caveats below). diff,sha256sum, and optionallyhusky/lint-stagedor pre-commit for commit-time hooks.- A POSIX-compatible shell (
bash4+ orzsh) for the seed scripts. On Windows, run everything inside WSL2 rather than PowerShell so the shell semantics match CI. - Optional but recommended: a
.ci/directory checked into the repository to hold baseline hashes and expected-environment dumps that the drift checks compare against.
Before writing any configuration, agree on a naming convention with the team and document it in .env.example. Prefix by domain (DATABASE_, REDIS_, AUTH_), use SCREAMING_SNAKE_CASE, and reserve a small set of well-known keys (NODE_ENV, LOG_LEVEL, PORT) that every service honors identically. A convention agreed up front is cheaper than a rename migration across twelve repositories later.
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. The template is the contract: it declares which keys exist, what type each value takes, and whether the value is required or optional. Concrete .env files — the ones holding real connection strings and tokens — stay out of history entirely. This separation is what lets a new hire clone the repository, copy the template, and know exactly which blanks to fill in without ever seeing a production secret.
- 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
The annotations are load-bearing, not decoration. When a value is malformed, the type hint next to the key is the difference between a five-second fix and a thirty-minute investigation. Keep the placeholder values valid — postgresql://postgres:postgres@localhost:5432/app_dev should actually connect against the default Compose database — so that copying the template yields a working local setup with zero edits for the common case.
# Track the template, ignore all concrete variants
!.env.example
.env
.env.*
Order matters in .gitignore. The negation !.env.example must appear before the broad .env.* glob, otherwise Git ignores the template too and the whole scheme collapses. Verify the policy holds with git check-ignore -v .env .env.example: the first should report a match, the second should print nothing. Run that check once after editing the ignore file and you will never accidentally track a secret through a mis-ordered pattern.
# .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
The hook compares only the key set, never the values — comparing values would either leak secrets into hook output or produce constant false positives, since every developer's DATABASE_URL legitimately differs. By diffing sorted key names alone, the check answers exactly one question: does this machine's .env declare the same variables the template promises? That is the invariant that matters for parity. A missing key means a service will read undefined at runtime; an extra key means a value is being set that no other environment knows about.
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. Docker Compose offers two mechanisms to pass configuration into a container — env_file and the inline environment list — and the interaction between them is the single most common source of "it works locally but not in CI" confusion. Understanding the precedence rule is not optional: environment entries always win over env_file entries for the same key, and shell variables present at docker compose up time win over both when a value uses ${VAR} interpolation.
- 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
Read the precedence from the bottom up. Compose loads .env first, then .env.local (later files override earlier ones for duplicate keys), then applies the inline environment block on top. That means NODE_ENV in this file is pinned to production regardless of what either env file says — a deliberate choice for a service you want to test in production mode, but a trap if you forget it is there. The anonymous /app/node_modules volume is unrelated to configuration but included because it is the standard companion pattern: it shadows the bind-mounted node_modules so the container uses its own installed dependencies rather than the host's, which matters when host and container architectures differ.
# 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
Never put a password in an environment value or an env_file. Both end up visible in docker inspect, in the process environment of every child process, and frequently in log aggregation that scrapes container metadata. Compose secrets mount the value as a file at /run/secrets/db_password inside the container, readable only by the service and never exposed through inspection. The application reads the file at startup and assembles the connection string in memory. For the full rotation and vault story, replace plaintext .env injection with ephemeral credential mounts using the patterns in local secret vaults and rotation.
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"
The --no-interpolate flag is the crux here. Without it, docker compose config expands every ${VAR} reference and bakes real secret values into the output — which you then hash and, worse, might commit. With it, the resolved schema (which keys map to which sources, which files layer in which order) is hashed while the secret values remain as literal ${VAR} placeholders. A change to the hash means someone altered the shape of the configuration — added a service, reordered env_file entries, introduced a new key — and that is exactly the kind of change CI should force a human to acknowledge.
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.
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. Instead of a README that lists twelve manual setup steps, a devcontainer encodes those steps in devcontainer.json and runs them automatically the first time a developer opens the project. The .env seeding happens in postCreateCommand, which means a developer who has never touched the repository gets a working, correctly-configured environment before they type a single command.
- 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" }
}
}
}
}
The test -f ... || cp ... idiom is deliberately idempotent: it seeds .env from the template only when no .env exists, so re-opening the container never clobbers a developer's local edits. containerEnv sets process-wide defaults that Compose interpolation and the application both see, while terminal.integrated.env.linux scopes the same values to the integrated terminal so an interactive npm run matches what the running service sees. Keeping those two in sync avoids the subtle class of bug where a command works in a task but fails in the terminal. For team-wide standardization of the editor layer itself, coordinate this with devcontainer configuration standards.
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"
This check closes the loop. It boots the devcontainer exactly as a developer would, extracts the environment the running process actually sees, filters to the keys that matter, and diffs against a committed expectation file. Run it in CI on every change to devcontainer.json or the Compose files and you catch configuration regressions — a renamed key, a dropped override, a changed default — at review time instead of when the next new hire hits a wall. The sequence of stages is what makes the guarantee hold end to end.
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. The distinction matters: the template holds values that are identical for every developer, while host-specific values — the machine's LAN address, its CPU architecture, the path to the Docker socket — differ per workstation and must never be committed. Writing them into .env.local instead of the tracked .env keeps the two concerns cleanly separated, and Compose's later-file-wins rule layers the local values on top automatically.
- 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."
The guard clause at the top is what makes the script safe to run repeatedly — a property you want because it will be invoked from a Makefile target, a devcontainer hook, and probably a nervous developer running it twice. The ${DOCKER_HOST:-unix:///var/run/docker.sock} parameter expansion supplies a sane default when the variable is unset, so the script produces a valid .env.local even on a machine with no Docker configuration in its shell profile. The 2>/dev/null | ... || echo '127.0.0.1' chain around hostname -I handles the reality that the command exists on Linux but not macOS, degrading gracefully to loopback rather than failing the whole bootstrap.
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"
bash -n parses the script and reports syntax errors without executing a single line, which makes it safe to run in CI on a runner that has no Docker socket and no business generating a real .env. Wire it into the same pipeline stage that runs your linters. A seed script is code, and code that generates configuration deserves the same static checking as the application it configures. For a fuller treatment of generating environment files inside a pipeline, see generating .env files from CI artifacts.
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.
Choose the Right Injection Method per Value
Not every value belongs in the same place. A team that dumps everything into a single .env eventually leaks a secret; a team that puts everything behind a vault slows onboarding to a crawl. The productive middle ground routes each value to the mechanism that matches its sensitivity and volatility. Public defaults live in the tracked template. Host-specific values go to the generated .env.local. Secrets go to file-backed Compose secrets or an external vault. Use the decision path below when you are unsure where a new key should live.
The three destinations map onto three trust boundaries. Anything in .env.example is world-readable — assume it will appear in a screenshot in a public issue someday, so it must contain no real credential. Anything in .env.local is machine-local and disposable; regenerating it must never lose important state, which is why the seed script derives it rather than a human editing it. Anything mounted as a secret is scoped to a single container and rotated out of band. Keeping these boundaries crisp is what lets a drift check be meaningful: the check only has to prove the key set matches, because the values live in the tier appropriate to their sensitivity and are validated by different controls.
Measure the Onboarding Payoff
Configuration discipline is easy to argue for in the abstract and hard to fund without numbers. Instrument the setup path once and the case makes itself. The measurements below come from a mid-size service team that adopted the template-plus-seed-script workflow described here and timed the "clone to first successful docker compose up" path before and after. The dominant cost before was manual .env construction: a new developer copied fragments from Slack, a wiki, and a teammate's screen-share, then spent the afternoon chasing typos and missing keys.
The gap between the template row and the seed-script row is the part teams underestimate. A template alone removes the guesswork about which keys exist, but a developer still fills in host-specific values by hand and still gets the socket path or LAN IP wrong on the first try. The seed script closes that last gap by deriving those values from the machine itself. The compounding effect matters at scale: on a team that onboards one engineer a month, moving from ninety-five minutes to twelve reclaims roughly seventeen hours of senior-engineer pairing time a year, because the failures that used to require a teammate to diagnose no longer happen. To connect these numbers to a broader onboarding-friction program, see measuring developer onboarding time.
Rollback - recovery
If a generated .env corrupts local state, restore from the tracked template and re-derive host values. Because the template is the single source of truth and the seed script is idempotent, recovery is a two-line operation with no manual reconstruction:
#!/usr/bin/env bash
set -euo pipefail
cp .env.example .env
bash scripts/bootstrap-env.sh --force
echo "Configuration reset from template"
The --force flag is what makes this a recovery and not a no-op: the seed script's guard clause normally refuses to overwrite an existing .env, and --force deliberately overrides that safety so the corrupt file is replaced. If the corruption reached committed files — a secret accidentally staged, a broken .gitignore — recover with git checkout -- .env.example .gitignore to restore the tracked versions, then rerun the reset above. Keep the .ci/env-baseline.sha256 file in history so that after any recovery you can rerun the Compose drift check and confirm the schema matches the last known-good state rather than trusting that the reset produced the right shape by eye.
Frequently Asked Questions
Should I ever commit a real .env file to Git?
No. Commit only .env.example with placeholder or non-sensitive default values. Every concrete variant (.env, .env.local, .env.production) must be ignored. If a real .env was ever committed, the secrets in it are compromised — rotate them, then purge the file from history with git filter-repo --path .env --invert-paths and force-push. Verify the ignore policy holds with git check-ignore -v .env, which should report a match for the concrete file while printing nothing for the template.
Which wins when the same key is set in both env_file and environment?
The inline environment value always wins over any env_file value for the same key. Within env_file, later files override earlier ones, so .env.local beats .env when both are listed. And a shell variable present at docker compose up time overrides both when the value uses ${VAR} interpolation. Run docker compose config to see the fully resolved result for any key without guessing.
How do I hash the Compose configuration without leaking secret values?
Use docker compose config --no-interpolate | sha256sum. The --no-interpolate flag leaves ${VAR} references as literal placeholders instead of expanding them to real values, so the hash captures the configuration schema — key names, file layering, service structure — without ever writing a secret to disk or into CI logs. Commit the resulting hash to .ci/env-baseline.sha256 and fail the pipeline when it changes unexpectedly.
Why put host-specific values in .env.local instead of .env?
Separating them keeps the drift check meaningful and the recovery path clean. .env mirrors the tracked template key-for-key, so a key-set diff against .env.example stays trivially true. Host-specific values (LAN IP, architecture, socket path) differ per machine and are derived by the seed script, so they belong in .env.local, which Compose layers on top via the later-file-wins rule. When something breaks, you can regenerate .env.local from the machine without touching the template-derived .env.