Why Config Goes to Environment Variables Instead of Hardcoding
Config is the part that changes behavior per environment (local, test, production) without changing code: database URLs, API endpoints, secrets, log levels. Hardcoding them means every environment change requires code edits and redeploys, and risks shipping a test DB address into production.
The 12-factor principle requires strict separation of config storage from code. Environment variables are the most common, framework-agnostic carrier — read uniformly whether you use Spring, Django or Node — and they support runtime injection, so switching environments needs no image rebuild.
The cost is management: once config scatters across hundreds of keys, naming, defaults and required-or-not become a maintenance burden, so you need tooling conventions as a safety net — which the following sections cover.
# 12-factor: one codebase, behavior differs by environment variables
export DATABASE_URL="mysql://app:***@prod-db:3306/shop"
export LOG_LEVEL="info"
# No more hardcoded connection strings; read env vars onlyEnvironment Variable Precedence: Don’t Be Fooled by Stale Values in .env
Same-name variables come from many places: system env, shell exports, .env files, .env.local, Docker -e, CI secrets. Who overrides whom is confusing, and the classic dotenv trap is "I changed .env but it seems to have no effect".
With Node’s dotenv, the default is not to override an existing system variable: if the process env already holds the same name, .env will not replace it. The consequence: you exported an old value in your local shell, and no matter how you edit .env it feels like nothing changed.
To be crystal clear, learn the general precedence: in-process explicit assignment > shell env > .env.local > .env > defaults. When a change "does not take", echo $KEY to see what the process actually resolved, then decide whether to override or delete the stale one.
# Check the effective values (parse the fields, do not guess)
printenv | grep -E '^(DATABASE_URL|NODE_ENV) ='
# Node does not override existing variables by default
# Force override requires an explicit flag
require('dotenv').config({ override: true })Set Rules with .env.example and Block Secrets with .gitignore
What should actually be committed is the template of config, not the values. Create .env.example or .env.sample listing every key with its purpose and whether it is required, using placeholders for real values. A new member clones, copies it to .env and fills local values — standardizing names without dragging secrets into the repo.
Also make sure to put the real .env in .gitignore. If .env ever appeared in a historical commit, gitignore alone cannot erase it — you must rewrite history (filter-branch or BFG) and force-push, then immediately rotate the leaked secrets. Such incidents cost far more than prevention.
# .env.example (commit this)
DATABASE_URL=mysql://user:pass@localhost:3306/shop
API_KEY=change-me
# .gitignore (append)
.env
.env.local
.env.*.localSplit Files per Environment and Agree on the Merge Order
Maintaining separate variable sets for local, test and production is common, but do not cram every environment’s values into a single .env — that effectively leaks production config to every developer. Safer is a combination of .env (common) + .env.local (local override only) + CI secrets (environment-specific).
The load order for the framework must be explicit. In the Nuxt/Vite ecosystem, the usual order is .env < .env.local < .env.<mode>. As long as the team agrees on this order and documents it in the README, and parsing stacks files in that sequence, you avoid arguments over which file wins.
Never store production secrets in a repo .env file; keep them in CI secrets management or the platform’s encrypted variables and inject at build time.
# Agreed order (document in README): .env is weakest, the last loaded wins
.env # Shared, all environments
.env.local # Local override, not committed
.env.staging # Staging environment (injected by CI)
# Nuxt CLI auto-loads the matching file based on modeVerify: Variable Resolution, Missing-Value Checks and Secret Rotation
Run three layers of validation before launch. First, resolution: at startup, print each required variable’s name and whether it is present, confirming .env loaded and the current mode picked the right file.
Second, missing-value validation: on startup traverse the set of required variables and fail fast with a clear error if any is absent, instead of blowing up midway through execution. Third, security: scan code and repo for plaintext secrets and reconcile the source of each production value to confirm it comes from secrets storage.
Rotate secrets regularly, especially after departures or log leaks. Use a dual-write transition: let both old and new keys work, revoke the old one only after everyone switches, avoiding a single-shot replacement that breaks production.
# Fail-fast validation example
const REQUIRED = ["DATABASE_URL", "API_KEY"];
for (const k of REQUIRED) {
if (!process.env[k]) throw new Error(`Missing required environment variable: ${k}`);
}
console.log("Config validation passed");