dotenv vs Platform Inject
dotenv loads key-value pairs from a .env file into process.env on your laptop. Production platforms inject environment variables at runtime from k8s Secrets, AWS SSM, Vault, or PaaS dashboards. The Node.js code path is the same; only the source changes.
Recipe
Quick-reference recipe card - copy-paste ready.
// src/load-env.ts
import { config as dotenvConfig } from "dotenv";
export function bootstrapEnv() {
if (process.env.NODE_ENV === "test") return;
if (process.env.NODE_ENV === "production") return; // platform injects env
dotenvConfig(); // development only
}# Dockerfile - NO COPY .env
ENV NODE_ENV=production
# DATABASE_URL set by k8s/ECS at runtime
CMD ["node", "dist/main.js"]When to reach for this:
- Onboarding developers who need local Postgres URLs without sharing prod credentials
- Auditing Docker images for accidental secret bake-in
- Designing CI pipelines that mirror production injection
- Migrating from
.envin repo to vault-backed prod
Working Example
// src/load-env.ts
import { config as dotenvConfig } from "dotenv";
import { existsSync } from "node:fs";
export function bootstrapEnv(): void {
const nodeEnv = process.env.NODE_ENV ?? "development";
if (nodeEnv === "production") {
// ECS task definition, k8s manifest, or Fly secrets set these
return;
}
if (nodeEnv === "test") {
// Jest/Vitest setup file sets process.env directly
return;
}
const path = existsSync(".env.local") ? ".env.local" : ".env";
const result = dotenvConfig({ path });
if (result.error && !existsSync(".env.example")) {
console.warn(`No ${path} found; copy .env.example to .env`);
}
}
// src/main.ts
import { bootstrapEnv } from "./load-env";
bootstrapEnv();
import { env } from "./env"; // Zod parse after bootstrap
import express from "express";
const app = express();
app.listen(env.PORT);# kubernetes deployment excerpt
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
- name: NODE_ENV
value: productionWhat this demonstrates:
dotenvruns only in development; production trusts the orchestrator- Docker image contains no
.envfile - Same
env.tsZod schema validates both local and prod sources
Deep Dive
How It Works
dotenvreads.envand assigns toprocess.envwithout overwriting existing keys (by default)- Platforms set env before
nodestarts - container entrypoint sees fullprocess.env - Precedence: platform env > shell export > dotenv file (when
override: false) - Secrets managers often fetch at boot and set
process.envprogrammatically before Zod parse
Environment Matrix
| Environment | Config source | dotenv? |
|---|---|---|
| Local dev | .env / .env.local | Yes |
| CI test | Workflow env + secrets | No |
| CI deploy | OIDC to cloud, inject at deploy | No |
| Staging/prod | k8s Secrets, SSM, Doppler | No |
Docker and Compose
# docker-compose.yml - dev only
services:
api:
env_file:
- .env
environment:
NODE_ENV: developmentenv_filein Compose is dev ergonomics, not production pattern- Production Compose (if used) should use Docker secrets or external vault
TypeScript Notes
// Explicit guard in schema for production misconfigs
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
}).refine(
(e) => e.NODE_ENV !== "production" || !e.DATABASE_URL.includes("localhost"),
{ message: "Production DATABASE_URL cannot point to localhost" }
);Gotchas
- Committing
.envto git - Instant credential leak. Fix:.gitignore.env; commit.env.examplewith placeholders only. COPY .envin Dockerfile - Secrets in image layers forever. Fix: Runtime inject only; scan images withdocker history.- dotenv in production dependencies - Encourages prod misuse. Fix:
devDependencieswhen only local dev loads files. - Assuming dotenv overrides platform -
override: truecan clobber k8s secrets in hybrid setups. Fix: Keep defaultoverride: false; skip dotenv in prod entirely. - Different keys per environment without schema - Staging missing
STRIPE_KEYfails at payment time. Fix: One Zod schema; CI validates each environment's manifest template.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
dotenv-cli in npm scripts | One-off local commands | Application runtime in prod |
| direnv | Per-directory shell env | Windows-heavy teams without WSL |
| Platform-only inject | Strict compliance | Local dev without .env.example docs |
| Secrets manager SDK at boot | Dynamic rotation | Simple static keys with k8s Secrets enough |
FAQs
Should dotenv be a dependency or devDependency?
devDependency if production never calls it. If bootstrapEnv ships in prod bundle but no-ops, either works; avoid bundling dotenv file reads in prod paths.
What about .env.local vs .env?
.env.local overrides local machine settings; gitignore both. Document precedence in README.
How does GitHub Actions inject secrets?
${{ secrets.DATABASE_URL }} in workflow env. Never echo secrets in logs; use OIDC to cloud for short-lived tokens when possible.
Can I use dotenv in Vitest?
Prefer vitest setupFiles that set process.env explicitly. Faster and deterministic than file IO.
Does Fastify need dotenv?
No. Load env before Fastify() instantiation, same as Express.
How do preview environments get config?
Platform inject per-branch URLs (Neon, Railway). Schema validates preview-specific DATABASE_URL hostnames.
Is AWS Parameter Store the same as env inject?
Often fetched at boot into process.env by startup script. Treat as secrets manager pattern, not dotenv.
Should NODE_ENV be in .env?
Yes locally (development). Production set by platform to production - do not rely on .env in prod.
How do I rotate DATABASE_URL?
Platform rolling update with new secret version. App drains connections on SIGTERM and reconnects with new env on restart.
What if developers share one .env in Slack?
Rotate all keys immediately. Move to per-dev credentials or local Docker Postgres with known password in .env.example only.
Related
- Configuration Basics - twelve-factor config
- Zod & env-schema Validation - validate after inject
- Secrets Managers - vault fetch patterns
- Docker Basics - image env conventions
- Configuration Best Practices - team policy
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.