Skip to content
KeyDrift
Scan for free
All posts

The .env file: a complete lifecycle guide

Creation, templating, load order, deployment injection, rotation, and deletion — every stage of an environment file's life, and the exact moment each leaks.

KeyDrift12 min read

The .env file: a complete lifecycle guide

The .env file is the most-used and least-examined artifact in modern development. Here is what happens at every stage of its life — creation through deletion — including the documented loading orders, the injection pitfalls, and the specific moment each stage tends to leak.

The .env file is the most widely deployed secrets technology in software, and almost nobody has read its manual. It ships inside language libraries (python-dotenv, dotenv for Node), gets re-implemented by frameworks with their own precedence rules, interacts with platform injectors in surprising ways, and quietly outlives the projects that created it. Most guidance covers exactly one moment — "don't commit it" — while the other six stages of its life go unexamined.

This guide walks the full lifecycle: birth, contract, loading, injection, runtime consumption, rotation, and death. Each section names the stage's characteristic leak mechanism, because leaks aren't randomly distributed across the lifecycle — they cluster predictably at transitions. Companion reading: how values escape into bundles and the CI surfaces that consume these files.

Birth: creating values

A key's life starts at a provider dashboard or CLI, passes through your clipboard, and lands in .env. The clipboard hop is the first underexamined surface: clipboard managers sync history across devices by design, meaning a key copied on a work laptop may sit in an iCloud/Google-linked phone's clipboard archive within seconds. No scanner watches there; the practical defense is provider-side — paste directly into platform secret stores when available, or at minimum treat "key on clipboard" as a transient exposure you consciously clear.

Shell history claims the second hop: export STRIPE_KEY=sk_live_… typed interactively persists into history files that sync via dotfile repos and backup tools. The mechanical fix is reading values from somewhere instead of typing them:

# Never paste live values inline; source them:
set -a; source ~/.secrets/stripe.env; set +a   # exported without touching history

And sharing deserves its rule: keys travel through password managers' secret links or not at all. Every chat message and ticket containing a live value creates a retention obligation nobody signed up for — chat platforms keep searchable history far longer than anyone expects.

The contract: .env.example

The template file is the lifecycle's least-leveraged artifact. Done properly, .env.example lists every variable name the application reads, with empty values or obviously synthetic examples — never real ones:

# .env.example — names are contract; values are placeholders
STRIPE_SECRET_KEY=            # server-only. Format sk_live_…/sk_test_…
NEXT_PUBLIC_API_URL=          # safe for browser bundle
SUPABASE_SERVICE_KEY=         # server-only. NEVER prefix for client use

Three functions follow from taking the template seriously:

  1. Onboarding stops requiring real secrets — new contributors boot from placeholders against sandboxes.
  2. CI can validate the contract: a pipeline step asserting every name in .env.example exists in the environment catches missing-variable surprises at merge time rather than deploy time.
  3. Drift becomes visible: when application code reads variables absent from the example file, that gap is itself a review signal — unnamed dependencies are how undeclared client-facing variables sneak past attention (the migration loop).

Loading: precedence you didn't choose

Libraries and frameworks layer .env files with precedence rules that differ per tool — and misremembered precedence causes both broken builds and false confidence about which values actually run.

Node's dotenv library loads one file and does not override already-set environment variables unless asked (override: true) — shell-exported values win over file values by default. Frameworks stack multiple files. Next.js documents this order explicitly (environment variables):

PriorityProductionDevelopment
Highest.env.production.local.env.development.local
↑.env.local.env.local
↑.env.production.env.development
Lowest.env.env

You can watch precedence behave locally. Install the loader once (npm install dotenv), then:

// order.mjs
import dotenv from "dotenv";

dotenv.config({ path: ".env" });
console.log("after base: ", process.env.GREETING ?? "(unset)");

dotenv.config({ path: ".env.local", override: true });
console.log("after local:", process.env.GREETING ?? "(unset)");
echo 'GREETING=from-base-env'    > .env       # synthetic values throughout
echo 'GREETING=from-local-env'   > .env.local
node order.mjs
after base:  from-base-env
after local: from-local-env

The security-relevant consequence: which file holds the value determines nothing about safety — .env.production.local holding SERVICE_KEY is as server-bound as any other, until some module with a public prefix references it. Precedence governs which value wins, never where values travel. That distinction trips people constantly: rotating a key by editing one file, while a higher-priority file still carries the old value, produces deployments that look rotated and aren't.

Injection: moving values into builds and platforms

Deployment transfers .env contents into platform mechanisms, and this transition hosts the lifecycle's most underestimated leak: build-time arguments.

Docker documentation explicitly warns against passing secrets via --build-arg and provides secret-mount alternatives (build secrets) because build args persist in image metadata and layer history — recoverable by anyone with registry pull access long after the variable served its momentary purpose:

# Wrong: ARG TOKEN survives in history even though the file is deleted later
ARG DEPLOY_TOKEN
RUN echo "$DEPLOY_TOKEN" > /tmp/token && ./install.sh && rm /tmp/token

# Right: mount-scoped, never persisted
RUN --mount=type=secret,id=deploy_token ./install.sh --token "$(cat /run/secrets/deploy_token)"

Platform dashboards repeat the pattern in friendlier clothes: variables entered once into hosting UIs flow into every subsequent build automatically. Combined with prefix conventions, the dashboard becomes a publication switch — adding a client-facing prefix there republishes whatever value it holds on next deploy. Injection isn't just transport; on build-time-substituting stacks it's materialization.

Verifying what a build actually received closes the loop: docker history --no-trunc <image> exposes ARG-derived layers; hosting platforms expose build logs where injected names (never values) can be listed. The audit question per injection channel is identical to the runtime one below — where does this value's journey terminate? — asked earlier, when answers are still cheap.

CI systems add their own rules: injected variables are masked in logs subject to the transformation limits covered in the CI guide, and they land in runner working directories where artifact globs can capture them.

Runtime: consumption decides everything

Once loaded, a value's safety depends entirely on which code consumes it:

  • Server contexts — route handlers, background jobs, server components — hold values in memory, log-free if hygiene holds. This is the entire safe harbor.
  • Client-included modules referencing prefixed names materialize literals into shipped JavaScript at build.
  • Test suites deserve explicit mention: tests read the same files, and test fixtures frequently get committed alongside mocked values that are occasionally less mock than intended. A test asserting against process.env.STRIPE_SECRET_KEY with mode detection ("skip unless live") is safer than fixtures embedding plausible keys.

The audit question per variable is therefore binary and answerable: can this name reach a client-included module? Answer yes, and the value must qualify as publishable (the tier system); answer no, and standard server hygiene applies.

Rotation: the stage everyone postpones

.env files make rotation feel expensive because values live in many places: platform settings, team members' local files, CI stores. The playbook generalizes elsewhere (provider-by-provider); the lifecycle-specific points:

  1. Rotate the source of truth first — platform store, then generate downstream copies from it, never the reverse. Local-file-first rotation guarantees drift.
  2. Overlap during migration: create the successor key before retiring the predecessor, migrate consumers, verify silence, retire. Providers supporting simultaneous keys make this routine.
  3. Purge superseded values deliberately: old entries in .env.local, stale platform variables, previous versions in CI stores. Rotation that leaves the retired value deployed-but-unused preserves its leak potential for whoever finds it later.

Scheduled rotation converts this from crisis response to maintenance — and event-driven rotation (team departures, provider advisories) layers on top.

Death: retirement and confirmation

Keys die twice: invalidated at the provider, then forgotten everywhere else. The second death requires verification — provider-side logs showing zero requests from the retired credential confirm no straggler consumer remains (Stripe's per-key request logs and AWS's credential-report last-used columns both serve this). Then the physical cleanup: entries removed from every store the lifecycle touched — local files across machines, platform variables, CI configs, password-manager items, and the chat threads from birth-stage sharing, should any exist.

An unretired-but-invalid key is mostly harmless; an unverified-retired one is how orphaned consumers fail mysteriously months later, and how "temporary" rollback paths stay warm indefinitely.

The consolidated leak map

StageCharacteristic leakControl
BirthClipboard sync, shell history, chat sharingSecret-link sharing; sourced exports
ContractReal values pasted into example filesSynthetic-only policy, CI validation
LoadingEditing the wrong precedence layerSingle source of truth; verify running values
InjectionBuild args persisted in images; dashboard prefixesSecret mounts; publication review
RuntimeClient modules consuming server valuesServer-only imports; bundle scanning
RotationPartial migrations; unpurged predecessorsOverlap pattern; zero-traffic verification
DeathOrphaned copies across storesDeliberate purge checklist

Every row's control is cheap; the file's reputation for danger comes entirely from stages handled by memory instead of procedure.

Team conventions that survive growth

Individual discipline breaks at team boundaries unless conventions absorb the coordination. Four earn their keep repeatedly:

  1. The naming contract. Prefixes encode jurisdiction: SERVER_ names must never appear in client-referenced modules; PUBLIC_ names may ship after review. The contract turns architecture into greppable text — CI can enforce it (an example guard), reviewers can check it mechanically, and assistants can learn it from the repo itself.
  2. Per-environment files over namespaces. .env.staging and .env.production with identical variable names beat one file of STAGING_DB_URL/PROD_DB_URL pairs: wrong-environment accidents require opening the wrong file rather than picking the wrong variable, and precedence layers stay interpretable.
  3. Onboarding through secret links. New contributors receive password-manager share links (expiry-bounded) mapping directly onto .env.example names — never chat pastes, never shared drives, never "here's my .env." The template plus links reconstructs a working environment without any real value transiting durable channels.
  4. The quarterly rotation drill. Rotate one non-production key as a team exercise against the playbook: time each step, note where documentation lagged, fix one thing. Drills cost thirty minutes and surface stale inventories before incidents do.

Each convention is boring by design. Their combined effect shows up only during stress — a departure handled by checklist instead of archaeology, an incident where the inventory was current because drills kept it warm. Boring conventions are what reliable looks like from the inside.

Two loading-stage footnotes worth pinning beside them: on Windows PowerShell, the sourced-export pattern translates to per-scope environment writes ($env:KEY = (Get-Content path | ...) or a secrets-manager CLI), with the same rule — values enter process environments without entering history files. And library behavior varies on overrides more than people expect: dotenv leaves existing variables untouched unless override: true, some framework loaders merge differently per mode, so when two sources disagree about a value, verify which one won from inside the running process (the precedence tables) rather than from either file.

A ten-minute health check

Run against any repository to locate its lifecycle gaps:

# 1. Tracked files carrying dotenv syntax (should be none):
git ls-files | xargs grep -lE '^[A-Z_]+=' 2>/dev/null | grep -vE 'example|template'

# 2. Layered files present locally (forgotten strata):
ls -la .env* 2>/dev/null

# 3. Names used in code but missing from the contract:
diff <(grep -oE 'process\.env\.[A-Z_]+' -r src --no-filename | sort -u | sed 's/process\.env\.//') \
     <(grep -oE '^[A-Z_]+' .env.example | sort -u)

# 4. Gitignore actually covering the family:
git check-ignore -v .env .env.local .env.production 2>/dev/null

Reading the results: command one failing means tracked files embed environment syntax — either the contract file (fine, named) or real values (incident-shaped, investigate immediately). Command three's output lists drift between code expectations and documented contract — every name on the left side missing from the right is an undeclared dependency inviting improvised setup later. Command four verifies the ignore rules match reality across the file family, since .env coverage alone leaves siblings exposed.

Ten minutes, run quarterly or whenever onboarding feels harder than it should, converts the lifecycle's quiet failure modes into a short list of named fixes. It also produces the artifact the lifecycle otherwise leaves undocumented: a current, verified statement of which variables exist, which layer wins for each, and where values actually live — the map every other practice in this guide assumes.

Platform dashboards are the stage a project reaches without ever deciding to: values migrate from local files into hosting UIs, and the dashboard becomes the de facto source of truth while .env files degrade into onboarding relics. Naming the transition matters because it changes the failure modes — dashboard entries lack diffs in pull requests, escape code review entirely, and accumulate across environments exactly like the layered files they replaced. Teams that treat dashboards as infrastructure export their inventories periodically (most platforms provide CLI or API access), diff them like code, and gate changes through the same review as application config. The file never dies; it becomes a generated view again — which is where stage two wants it.

FAQ

Should we abandon .env for a secrets manager? Not necessarily — maturity staging covers when upgrades pay off. A disciplined .env lifecycle beats an undisciplined vault deployment. The file itself was never the main risk; unmanaged transitions were.

Does .gitignore cover us? It covers the commit path only — historically the smallest of the seven stages' exposures. Injection, runtime consumption, and rotation stages operate entirely outside git's view.

Multiple environments: separate files or one file with namespaces? Separate files per environment (.env.staging, .env.production) with identical variable names reduce wrong-environment accidents versus namespace-prefixed single files — precedence mistakes become impossible when files can't co-load. Platform injection should ultimately replace local production files entirely.

How do we know which values are currently live? Ask the runtime, not the files: a debug endpoint (protected!) or startup log listing variable names and value fingerprints — never values — reconciles intention against reality in seconds.


Your .env discipline protects storage, not deployment. Check what actually ships: KeyDrift's free scan — minutes, no signup.

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.