Environment Configuration
dotenv, Secrets, and Fail-Fast Validation
LinkedIn Hook
"Your app crashed in production at 3 AM because DATABASE_URL was undefined. Again."
Most Node.js apps treat configuration as an afterthought. A
.envfile gets copy-pasted between machines, secrets land in Git history, and nobody validates that the variables actually exist until the code tries to use them — three hours after deployment, when the first user hits the broken endpoint.The 12-factor app principle is brutally simple: store config in the environment, never in code, and fail loudly when something is missing. Yet teams still hardcode API keys "just for staging," commit
.envfiles "just this once," and ship apps that boot successfully with half their config undefined.The fix is not complicated. Load environment variables with
dotenvin development. Validate them at startup with a Zod schema. Fetch real secrets from AWS Secrets Manager or Vault in production. If anything is missing or malformed, crash before the first request — not after the first invoice fails to send.In Lesson 10.3, I break down environment configuration in Node.js: dotenv precedence, fail-fast validation, secrets managers, and the difference between startup config and runtime config.
Read the full lesson -> [link]
#NodeJS #TwelveFactor #DevOps #Secrets #BackendDevelopment #InterviewPrep
What You'll Learn
- How
dotenvloads.envfiles and the precedence rules between files and process env - Config libraries like
node-configandconvictand when to reach for them - Environment-specific configuration patterns for dev, staging, and production
- Secrets management with AWS Secrets Manager, HashiCorp Vault, and plain env vars
- The 12-factor app config principle and why it matters in containerized deployments
- Fail-fast validation with Zod so missing config crashes the process at boot
- The difference between startup config (parsed once) and runtime config (hot-reloaded)
The Restaurant Kitchen Analogy — Why Config Belongs Outside the Code
Imagine a chef who hardcodes the salt amount into every recipe in the cookbook. The recipe says "add 12 grams of salt" — and that number is printed in ink on every page. Now the restaurant opens a second location at high altitude, where the same dish needs more salt to taste right. The chef has to reprint the entire cookbook for the new location. Open a third location? Reprint again. A supplier change? Reprint again.
Now imagine the chef writes recipes that say "add salt to taste, see today's seasoning sheet." The cookbook never changes. Each kitchen has its own seasoning sheet on the wall, tuned to that location. The same recipe runs unchanged in every restaurant, and a new location only needs a new seasoning sheet.
That is exactly the difference between hardcoded configuration and environment-driven configuration. Your code is the cookbook — it must be identical across dev, staging, and production. The seasoning sheet is the environment — DATABASE_URL, API_KEY, LOG_LEVEL. Each environment has its own values, but the code that consumes them never changes. Build once, deploy anywhere.
+---------------------------------------------------------------+
| HARDCODED CONFIG (The Problem) |
+---------------------------------------------------------------+
| |
| const db = connect("postgres://user:pass@prod-db:5432/app"); |
| const apiKey = "sk_live_abc123xyz"; |
| const logLevel = "info"; |
| |
| Problems: |
| - Secrets committed to Git history (forever) |
| - Cannot deploy same build to dev/staging/prod |
| - Rotating a key requires a code change + redeploy |
| - Anyone with repo access has production credentials |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| ENV-DRIVEN CONFIG (The 12-Factor Way) |
+---------------------------------------------------------------+
| |
| const db = connect(process.env.DATABASE_URL); |
| const apiKey = process.env.STRIPE_SECRET_KEY; |
| const logLevel = process.env.LOG_LEVEL ?? "info"; |
| |
| Benefits: |
| - Same image runs in dev, staging, and prod |
| - Secrets live in a vault, not in source |
| - Rotation = update env var + restart, no rebuild |
| - Validated at startup, crashes loudly if missing |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Split comparison: LEFT side labeled 'Hardcoded' shows source code with red highlights on string literals containing secrets, with a red GitHub icon leaking keys. RIGHT side labeled '12-Factor' shows the same code referencing process.env with green Node (#68a063) highlights and an amber (#ffb020) vault icon. White monospace labels. Amber arrow showing the boot sequence: load -> validate -> run."
Loading .env Files with dotenv
The dotenv package reads a .env file and assigns each line to process.env. It is the de facto standard for local development in Node.js. The rule is simple: dotenv never overrides variables that already exist in process.env. This is intentional — it means real environment variables (set by your shell, your container orchestrator, or your CI runner) always win over file-based defaults.
Basic Usage and Precedence
// app.js
// Load .env into process.env as early as possible — before any module
// that reads process.env is imported. This is the most common bug.
require('dotenv').config();
// Now read variables. dotenv has already populated process.env.
const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;
console.log('Starting on port', port);
console.log('DB target:', dbUrl);
# .env (committed as .env.example, never as .env)
PORT=3000
DATABASE_URL=postgres://localhost:5432/app_dev
LOG_LEVEL=debug
Precedence — Real Env Vars Always Win
// config-loader.js
// Demonstrates precedence: process env > .env.<NODE_ENV> > .env
const dotenv = require('dotenv');
const path = require('path');
const env = process.env.NODE_ENV || 'development';
// Load environment-specific file first (lowest priority among files,
// because dotenv will not overwrite already-set variables).
// We actually load the MOST specific file first so its values stick,
// then the generic .env fills in any remaining gaps.
dotenv.config({ path: path.resolve(process.cwd(), `.env.${env}`) });
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
// Anything set in the real shell (e.g. `DATABASE_URL=... node app.js`)
// was already in process.env when dotenv ran, so dotenv left it alone.
// Final precedence (highest -> lowest):
// 1. Real environment variables (shell, container, CI)
// 2. .env.<NODE_ENV> (e.g. .env.production)
// 3. .env (shared defaults)
module.exports = process.env;
Critical rule: call dotenv.config() before any require() that reads env vars. If you import your database module at the top of app.js and call dotenv.config() after it, the database module already cached process.env.DATABASE_URL as undefined. The fix is to put the dotenv call first, or to use node -r dotenv/config app.js so it loads before your code runs at all.
.gitignore — The One File You Must Get Right
# .gitignore
.env
.env.local
.env.*.local
# Allow templates so other developers know what variables to set
!.env.example
!.env.template
Commit .env.example with placeholder values (DATABASE_URL=postgres://user:pass@host/db) so new developers can copy it to .env and fill in real values. Never commit the real .env. Once a secret hits Git history, it is compromised forever — even if you delete the file later, the value lives on in old commits, forks, and clones.
Validating Config at Startup with Zod — Fail Fast
Reading process.env directly scatters string parsing throughout your codebase. Worse, every variable is either a string or undefined, with no type safety and no validation. The fix is to parse the entire environment once at startup, validate it against a schema, and crash the process if anything is wrong. This pattern is called fail-fast configuration, and it is the single highest-leverage change you can make to your config story.
// config.js
// Parse and validate the environment exactly once, at module load time.
// If anything is missing or malformed, this throws and the process exits
// with a non-zero code BEFORE any HTTP server is listening.
const { z } = require('zod');
// Define the shape of every environment variable the app needs.
// String coercion handles the fact that all env vars arrive as strings.
const EnvSchema = z.object({
// Required values — no default, must be set
NODE_ENV: z.enum(['development', 'staging', 'production', 'test']),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
// Numeric values — coerce strings to numbers, then validate
PORT: z.coerce.number().int().positive().default(3000),
DB_POOL_MAX: z.coerce.number().int().min(1).max(100).default(10),
// Optional values with sensible defaults
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Boolean coercion — env vars are strings like "true"/"false"
ENABLE_METRICS: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
// Conditional secrets — only required outside development
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
});
// Parse process.env. If validation fails, print every error and exit.
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
for (const issue of parsed.error.issues) {
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
}
// Exit with a non-zero code so orchestrators (Docker, k8s, systemd)
// see the failure and restart or alert.
process.exit(1);
}
// Production-only invariants that Zod cannot express directly
if (parsed.data.NODE_ENV === 'production' && !parsed.data.STRIPE_SECRET_KEY) {
console.error('STRIPE_SECRET_KEY is required in production');
process.exit(1);
}
// Freeze the config so nothing can mutate it at runtime
module.exports = Object.freeze(parsed.data);
// server.js
// Importing config triggers validation. If anything is wrong, the
// process dies here — before Express even loads.
const config = require('./config');
const express = require('express');
const app = express();
// Now config is fully typed and guaranteed valid. No defensive checks
// needed downstream.
app.listen(config.PORT, () => {
console.log(`Server listening on ${config.PORT} (${config.NODE_ENV})`);
});
The win here is enormous. Without validation, a typo like DATABSE_URL (missing the A) would silently produce undefined, your app would start happily, and the first request would crash with a confusing "cannot read properties of undefined" error. With Zod, the process refuses to boot and tells you exactly which variable is wrong. A crashed boot is infinitely better than a half-broken running server.
Secrets Management — Beyond .env Files
Environment variables are great for non-sensitive config and acceptable for development secrets. But in production, you should not be storing secrets as plain env vars set in a CI dashboard. The reasons:
- Anyone with access to the deployment pipeline sees them in plaintext
- Rotating a secret requires a redeploy
- Secrets get logged accidentally (
console.log(process.env)) - No audit trail of who read which secret when
A secrets manager solves this. The app boots, authenticates to the secrets manager using a workload identity (IAM role, Kubernetes service account, Vault token), fetches the secrets at startup, and injects them into config. The secrets never live on disk, never appear in env vars at the OS level, and rotation is a single API call.
// secrets.js
// Sketch of fetching secrets from AWS Secrets Manager at startup.
// In production, the EC2/ECS/EKS instance role grants access — no
// AWS keys are needed in the environment.
const {
SecretsManagerClient,
GetSecretValueCommand,
} = require('@aws-sdk/client-secrets-manager');
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
// Fetch a single secret by name and parse it as JSON.
// Secrets Manager stores secrets as either plain strings or JSON blobs.
// JSON is preferred so one secret can hold many related values.
async function fetchSecret(secretId) {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretId })
);
if (!response.SecretString) {
throw new Error(`Secret ${secretId} has no SecretString`);
}
return JSON.parse(response.SecretString);
}
// Bootstrap function called from the main entry point BEFORE the
// HTTP server starts. Failure here crashes the process — fail fast.
async function loadSecrets() {
// In dev, fall back to local .env values (already loaded by dotenv).
if (process.env.NODE_ENV === 'development') {
return {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
};
}
// Production: fetch the JSON blob containing all app secrets.
// The secret name is the only thing the app needs to know — the
// actual values live in AWS, encrypted at rest with KMS.
const secrets = await fetchSecret(process.env.APP_SECRETS_ARN);
// Inject into process.env so downstream code (and Zod validation)
// sees them as if they had been set normally. Alternatively,
// pass them directly into your config module.
for (const [key, value] of Object.entries(secrets)) {
process.env[key] = value;
}
return secrets;
}
module.exports = { loadSecrets };
// index.js — entry point
// Note: secrets must be loaded BEFORE config validation runs.
async function main() {
// 1. Load .env (dev only — no-op in prod where there is no .env file)
require('dotenv').config();
// 2. Fetch real secrets from the secrets manager (prod) or .env (dev)
const { loadSecrets } = require('./secrets');
await loadSecrets();
// 3. Now require config — Zod validation runs against the populated env
const config = require('./config');
// 4. Start the server
const { startServer } = require('./server');
await startServer(config);
}
main().catch((err) => {
console.error('Boot failed:', err);
process.exit(1);
});
Other secrets managers follow the same pattern: HashiCorp Vault uses a token or AppRole login, Google Secret Manager uses workload identity, Azure Key Vault uses managed identities, and Doppler/Infisical inject via a CLI wrapper. The key insight is the same: the application code never knows the actual secret values until startup, and the developer never sees them at all.
The 12-Factor App Config Principle
The 12-factor methodology (factor III) states: Store config in the environment. Specifically, anything that varies between deployments — credentials, hostnames, feature flags, resource handles — must live in environment variables, not in code, not in config files committed to source control, and not in a "config" directory of constants.
+---------------------------------------------------------------+
| THE 12-FACTOR CONFIG RULES |
+---------------------------------------------------------------+
| |
| WHAT BELONGS IN THE ENVIRONMENT: |
| - Database URLs, Redis URLs, Kafka brokers |
| - API keys, JWT secrets, OAuth client secrets |
| - Hostnames, ports, timeouts |
| - Feature flags that vary per environment |
| - Log levels, telemetry endpoints |
| |
| WHAT DOES NOT BELONG (keep in code): |
| - Business logic constants (TAX_RATE, MAX_CART_ITEMS) |
| - Routing tables, validation rules |
| - Anything that is the SAME across all deployments |
| |
| THE LITMUS TEST: |
| "Could this codebase be open-sourced right now without |
| leaking any credentials?" If no, you are violating 12F. |
| |
+---------------------------------------------------------------+
The benefit is strict separation of config from code. The same Docker image you tested in staging runs unmodified in production — only the environment differs. There is no if (env === 'production') branching scattered through the code, no config/production.json file shipping with the build, no risk of pushing a dev value to prod by accident.
Anti-pattern: grouping environments inside the code, like config.development.dbUrl and config.production.dbUrl. This forces every environment's config into every deployment, mixes secrets with non-secrets, and creates the temptation to add a new "qa2" environment by editing the file. The 12-factor way is one set of variables per running process, supplied externally.
Startup Config vs Runtime Config
There are two kinds of configuration, and confusing them causes real bugs.
Startup config is read once when the process boots, validated, frozen, and never changed. Database connection strings, secret keys, listen ports, framework settings — all startup config. If you need to change them, you restart the process. This is the default and what 99% of config should be.
Runtime config is values that legitimately change while the process is running, without a restart. Examples: feature flags toggled by a product manager, rate-limit thresholds tuned in response to load, A/B test allocations. Runtime config typically lives in a dedicated system — LaunchDarkly, Unleash, ConfigCat, or a database table polled every N seconds — and is fetched on demand or via subscription.
+---------------------------------------------------------------+
| STARTUP vs RUNTIME CONFIG |
+---------------------------------------------------------------+
| |
| STARTUP CONFIG (process.env + Zod): |
| - DATABASE_URL, JWT_SECRET, PORT |
| - Read once at boot, validated, frozen |
| - Change requires process restart |
| - Source: env vars / secrets manager |
| |
| RUNTIME CONFIG (feature flag service): |
| - enableNewCheckout, maxUploadMB, abTestVariant |
| - Polled or pushed while process runs |
| - Change takes effect within seconds, no restart |
| - Source: LaunchDarkly, Unleash, DB table |
| |
| RULE OF THUMB: |
| If changing it requires touching infrastructure -> startup |
| If a PM should change it without engineering -> runtime |
| |
+---------------------------------------------------------------+
Mixing the two is painful. Putting feature flags in env vars means every flag flip is a deploy. Putting your database URL in a feature flag service means a network outage takes down your boot sequence. Pick the right tool for each kind of value.
Config Libraries — node-config and convict
For simple apps, dotenv + Zod is enough. For larger apps with many environments, hierarchical settings, and command-line overrides, dedicated config libraries help.
node-config uses a config/ directory with files like default.json, production.json, staging.json. Files are merged in order: default -> <NODE_ENV> -> local -> environment variables. It is convention-heavy and works well when most config is non-sensitive defaults with a small number of env-driven overrides.
convict (originally from Mozilla) defines a schema in code with types, defaults, env var bindings, command-line argument bindings, and validation. It produces a typed config object and validates at load time — similar in spirit to the Zod approach but built specifically for config.
// convict-example.js
const convict = require('convict');
// Schema doubles as documentation: every option has a doc string,
// a format, a default, and an env var binding.
const config = convict({
env: {
doc: 'The application environment',
format: ['development', 'staging', 'production'],
default: 'development',
env: 'NODE_ENV',
},
port: {
doc: 'The port to bind',
format: 'port',
default: 3000,
env: 'PORT',
arg: 'port', // also bindable via --port=8080
},
db: {
url: {
doc: 'PostgreSQL connection string',
format: String,
default: '',
env: 'DATABASE_URL',
sensitive: true, // hidden when config is logged
},
},
});
// Validate — throws if anything is malformed
config.validate({ allowed: 'strict' });
module.exports = config;
For most modern apps, the dotenv + Zod combination is simpler, more flexible, and gives you a TypeScript type for free via z.infer. Reach for node-config or convict only when you have many environments, hierarchical settings, or strict ops requirements.
Common Mistakes
1. Committing .env to Git.
This is the cardinal sin. Once a secret hits a public or even a private repo, treat it as compromised — rotate it immediately. Add .env to .gitignore on day one of every project, and commit a .env.example with placeholder values so new developers know what to set. Use tools like gitleaks or trufflehog in CI to fail builds that accidentally introduce secrets.
2. Reading process.env everywhere instead of validating once.
Scattering process.env.X ?? 'default' throughout the codebase produces inconsistent defaults, untyped values, and silent bugs when a variable is misspelled. Parse and validate the entire environment in a single config.js module at startup, freeze it, and import the typed config object everywhere else. No other file should touch process.env directly.
3. Hardcoding values "just for now." Hardcoded API keys, database URLs, and "TODO: move this to env" constants always survive longer than intended. They end up in the production build, get committed to Git, and leak. The fix is to make environment variables required from the very first commit — even in toy projects.
4. Loading dotenv after importing modules that read env vars.
If your database client reads process.env.DATABASE_URL at import time, and you call dotenv.config() after that import, the client sees undefined. Always call dotenv.config() (or use node -r dotenv/config) before any other require. Better yet, do all config loading in an async main() function and import the rest of the app from there.
5. Treating secrets as runtime config. Secrets should be loaded once at startup and frozen. If you fetch a secret from AWS Secrets Manager on every request, you are paying API latency, hitting rate limits, and creating a hard dependency on the secrets service for serving traffic. Cache secrets in memory, refresh them on a schedule if rotation matters, and let the next deploy pick up changes if rotation does not.
Interview Questions
1. "How does dotenv decide which value wins when the same variable is set in multiple places?"
Dotenv has a strict precedence rule: it never overrides a variable that is already set in process.env. Real environment variables (set by your shell, your container orchestrator, or your CI runner) always win over anything in a .env file. Among .env files, the order depends on which file you load first — the first call to dotenv.config() wins for any given key, because subsequent calls also refuse to overwrite. The standard pattern is to load the most specific file first (.env.production) and the generic .env second, so production-specific values stick and the generic file fills in defaults. This precedence model is intentional: it means production deployments using real env vars are never accidentally shadowed by a stray .env file on the server.
2. "Why should you validate environment variables at startup instead of when they are first used?"
Because a half-validated process is the worst possible state. If you read process.env.STRIPE_SECRET_KEY only when the first payment request arrives, your server boots successfully, accepts traffic, and crashes the first paying customer with a confusing error three hours after deployment. Startup validation with a Zod schema parses every required variable, checks types and constraints, and exits the process with a non-zero code if anything is wrong — before the HTTP server is even listening. Container orchestrators like Kubernetes see the failed boot, refuse to roll out the new version, and keep the old healthy pods running. A crashed boot is a feature, not a bug: it converts subtle runtime errors into loud, immediate, and obvious deployment failures.
3. "What is the difference between environment variables and a secrets manager, and when should you use each?"
Environment variables are fine for non-sensitive config (ports, log levels, feature toggles) and acceptable for development secrets stored in a local .env file. They become a liability for production secrets because they are visible in plaintext to anyone with access to the deploy pipeline, they are easy to log accidentally, they have no audit trail, and rotation requires a redeploy. A secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, Azure Key Vault) stores encrypted secrets centrally, grants access via workload identity (IAM role, service account) so no API keys live in the environment, audits every read, and supports rotation without code changes. The pattern is: app boots, authenticates to the secrets manager using its instance identity, fetches secrets once, validates them, and frees them only when the process restarts.
4. "Explain the 12-factor app principle for configuration. Why does it matter for containerized deployments?"
Factor III of the 12-factor methodology says: store config in the environment, strictly separated from code. This means no hardcoded credentials, no environment-specific files committed to source control, and no if (env === 'production') branching in business logic. The same build artifact must run unchanged in dev, staging, and production — only the environment variables differ. This matters enormously for containerization because the entire promise of Docker is "build once, run anywhere." If your image contains environment-specific config baked in, you have to build a separate image per environment, breaking immutability and creating drift between what you tested and what you deployed. With 12-factor config, the same image hash flows from CI to staging to prod, with environment variables injected at container startup by Kubernetes, ECS, or whichever orchestrator you use. This eliminates an entire category of "works in staging, broken in prod" bugs.
5. "What is the difference between startup config and runtime config, and how should they be implemented differently?"
Startup config is read once when the process boots, validated against a schema, frozen, and never changed for the lifetime of the process. Database URLs, secret keys, listen ports, and framework tuning all belong here — if you need to change them, you restart. The implementation is process.env plus a Zod schema, loaded at the very top of your entry point. Runtime config is values that legitimately need to change while the process keeps running: feature flags, A/B test allocations, rate-limit thresholds tuned by ops in response to load. The implementation is a dedicated feature flag service (LaunchDarkly, Unleash, ConfigCat) or a database table polled on a schedule. Mixing the two causes pain: putting feature flags in env vars means every flag flip is a deploy, while putting your database URL in a feature flag service couples your boot sequence to a third-party service. The rule of thumb: if changing it requires touching infrastructure, it is startup config; if a product manager should change it without involving engineering, it is runtime config.
Quick Reference — Environment Configuration Cheat Sheet
+---------------------------------------------------------------+
| ENV CONFIG CHEAT SHEET |
+---------------------------------------------------------------+
| |
| LOAD .env IN DEV: |
| require('dotenv').config() // FIRST line of entry point |
| node -r dotenv/config app.js // alternative |
| |
| PRECEDENCE (highest -> lowest): |
| 1. Real shell env vars |
| 2. .env.<NODE_ENV> |
| 3. .env |
| 4. Schema defaults |
| |
| VALIDATE WITH ZOD: |
| const env = EnvSchema.parse(process.env) |
| // Throws and exits on missing/malformed values |
| |
| SECRETS IN PROD: |
| AWS Secrets Manager / Vault / Google Secret Manager |
| Fetch at startup, inject into process.env, validate |
| |
| GIT HYGIENE: |
| .gitignore: .env, .env.local, .env.*.local |
| Commit: .env.example with placeholders |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Never commit .env — only .env.example |
| 2. Validate ALL env vars at startup with Zod |
| 3. Fail fast: process.exit(1) on invalid config |
| 4. Read process.env in ONE place (config.js), nowhere else |
| 5. Same image, different env vars per environment |
| 6. Production secrets live in a secrets manager, not env vars |
| 7. Startup config != runtime config — use the right tool |
| 8. Freeze the config object so nothing mutates it |
| |
+---------------------------------------------------------------+
| Concern | Wrong Way | Right Way |
|---|---|---|
| Storing secrets | Hardcoded in source | Secrets manager + IAM |
| Loading .env | After other requires | First line of entry point |
| Validation | Check at first use | Zod schema at startup |
| Missing var | Silent undefined | process.exit(1) with message |
| Per-environment | if (env === 'prod') | Same code, different env vars |
| Feature flags | Env vars | LaunchDarkly / Unleash |
| Rotating secrets | Redeploy | Update vault, restart |
| Sharing config shape | README docs | .env.example template |
Prev: Lesson 10.2 -- Logging and Monitoring Next: Lesson 10.4 -- Dockerizing Node.js
This is Lesson 10.3 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.