Every Node.js developer has done it. You clone a repo, create a .env file, paste in your secrets, and move on. It works. Until it doesn't.
Maybe a teammate accidentally commits the file. Maybe you rotate a key in one place but forget another. Maybe your staging and production environments silently diverge because someone manually edited a value six months ago and nobody documented it.
.env files are a good start, but most developers are using them in ways that create real, compounding problems. Let's fix that.
What Most People Get Wrong
1. No Validation at Startup
The most common mistake: your app starts, everything looks fine, and then 30 minutes later a user hits a code path that needs STRIPE_SECRET_KEY — which is undefined because you forgot to add it to the new server.
// The wrong way — silent failure
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// STRIPE_SECRET_KEY is undefined? Stripe SDK initializes anyway.
// You won't know until a payment fails.
Validate your environment at startup. Fail loud, fail early.
// config.js — validate everything before your app boots
const required = [
'DATABASE_URL',
'STRIPE_SECRET_KEY',
'JWT_SECRET',
'REDIS_URL',
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:');
missing.forEach((key) => console.error(` - ${key}`));
process.exit(1);
}
module.exports = {
database: { url: process.env.DATABASE_URL },
stripe: { secretKey: process.env.STRIPE_SECRET_KEY },
jwt: { secret: process.env.JWT_SECRET },
redis: { url: process.env.REDIS_URL },
};
Now your app refuses to boot with a clear error message. Your infrastructure team will thank you.
2. Accessing process.env Everywhere
Scattering process.env.WHATEVER calls throughout your codebase makes things hard to test, hard to refactor, and easy to typo.
// Bad — scattered access
const db = new Database(process.env.DATABASE_URL);
const client = new RedisClient(process.env.REDIS_URL);
const mailer = new Mailer(process.env.SMTP_HOST, process.env.SMTP_PORT);
Instead, centralize all env access through a single config module:
// config.js — single source of truth
require('dotenv').config();
const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
database: {
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10),
},
redis: {
url: process.env.REDIS_URL,
ttl: parseInt(process.env.REDIS_TTL || '3600', 10),
},
mail: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
};
module.exports = config;
// Anywhere in your app
const config = require('./config');
const db = new Database(config.database.url);
Now if you ever rename an env var, you change it in exactly one place.
3. Using the Same .env for Everything
Having a single .env file means your local dev secrets, staging credentials, and production tokens live in the same mental bucket. Developers start copy-pasting production values locally "just for testing," and now your dev machine has live production database access.
Use multiple environment files:
.env # Committed to git — safe defaults only, no secrets
.env.local # Local overrides — gitignored
.env.staging # Staging values — gitignored, stored in CI/CD secrets
.env.production # Production values — never on local machines
Your .env file in git should only contain non-sensitive defaults:
# .env (safe to commit)
NODE_ENV=development
PORT=3000
DB_POOL_SIZE=5
REDIS_TTL=3600
LOG_LEVEL=debug
# DO NOT put real secrets here.
# Use .env.local for local development secrets.
# DATABASE_URL=
# STRIPE_SECRET_KEY=
# JWT_SECRET=
Level Up: Use a Schema Validator
For serious projects, skip manual validation and use a library like zod or envalid. Here's a pattern I love with zod:
// config.js
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'development'}` });
require('dotenv').config({ path: '.env.local', override: false });
require('dotenv').config({ override: false });
const { z } = require('zod');
const schema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
});
const result = schema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment configuration:');
console.error(result.error.format());
process.exit(1);
}
module.exports = result.data;
This gives you:
- Type coercion (string
PORTbecomes a number) - Format validation (
STRIPE_SECRET_KEYmust start withsk_) - Minimum length checks for secrets
- Clear error messages when something is wrong
The Production Reality: Don't Use .env Files in Production
Here's a take that might surprise you: .env files are a development convenience. In production, you should not be using them at all.
Instead:
For containers (Docker/Kubernetes): Inject secrets as environment variables directly via your orchestrator. Kubernetes has native Secrets. Docker Swarm has secrets. Don't bake .env files into images.
For cloud platforms: Use the platform's secret management:
- AWS Secrets Manager or Parameter Store
- Google Cloud Secret Manager
- Azure Key Vault
- HashiCorp Vault for multi-cloud
- Vercel/Railway/Render environment variable UIs
The pattern is: your app reads from process.env, but how those values get into process.env changes by environment. In dev, dotenv loads a file. In production, the platform injects them directly.
# Dockerfile — notice no COPY .env
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml for local dev only
services:
app:
build: .
env_file:
- .env.local
ports:
- "3000:3000"
Practical Checklist
Here is what a healthy environment variable setup looks like:
-
.env.examplecommitted to git with all keys documented, values empty or safe defaults -
.gitignorecontains.env,.env.local,.env.*.local - App validates all required vars at startup, exits if any are missing
- Single
config.jsmodule — no directprocess.envaccess outside it - Different credentials for dev, staging, and production (never share)
- Production secrets live in a secret manager, not flat files
- Secret rotation is documented and can be done without redeploying
One More Thing: Secret Rotation
This is the thing nobody thinks about until 2 AM when a key gets leaked. Before you ship to production, answer this question:
If I had to rotate this secret right now, how long would it take and how many places do I need to update?
If the answer is "I'm not sure" or "a really long time," that's a problem. Good secret management means:
- Secrets are referenced by name, not value (so rotation is a config change, not a code change)
- You can deploy new secrets without downtime (run both old and new in parallel briefly)
- You have a runbook for rotation so a junior dev can do it at 2 AM without panic
Wrapping Up
The .env pattern is not inherently bad. It's simple, portable, and understood by every Node.js developer. The problems come when teams treat it as a production-grade secret management system instead of the development convenience it actually is.
Start with the basics: validate at startup, centralize access, separate files per environment. Then, as your project grows, graduate to a proper secret manager.
Your future self — the one who gets paged at 2 AM because a secret was committed to git — will be very glad you did.
What does your current secret management setup look like? Drop it in the comments. I'm curious how teams of different sizes handle this.
Top comments (2)
You’ll probably like varlock.dev - it’s a free open source toolkit that replaces dotenv and solves a lot of these problems.
Thanks for the suggestion, Theo! Just checked out varlock — the
@decoratorcomments approach for schema validation is really clever, especially the built-in sensitive value redaction and leak detection. Feels like a solid evolution over dotenv + manual validation. Gonna give it a spin on my next project 👍