DEV Community

Cover image for Stop Using .env Files Wrong: A Better Way to Manage Secrets in Node.js
Teguh Coding
Teguh Coding

Posted on

Stop Using .env Files Wrong: A Better Way to Manage Secrets in Node.js

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.
Enter fullscreen mode Exit fullscreen mode

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 },
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// Anywhere in your app
const config = require('./config');
const db = new Database(config.database.url);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

This gives you:

  • Type coercion (string PORT becomes a number)
  • Format validation (STRIPE_SECRET_KEY must start with sk_)
  • 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"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml for local dev only
services:
  app:
    build: .
    env_file:
      - .env.local
    ports:
      - "3000:3000"
Enter fullscreen mode Exit fullscreen mode

Practical Checklist

Here is what a healthy environment variable setup looks like:

  • .env.example committed to git with all keys documented, values empty or safe defaults
  • .gitignore contains .env, .env.local, .env.*.local
  • App validates all required vars at startup, exits if any are missing
  • Single config.js module — no direct process.env access 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:

  1. Secrets are referenced by name, not value (so rotation is a config change, not a code change)
  2. You can deploy new secrets without downtime (run both old and new in parallel briefly)
  3. 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)

Collapse
 
theoephraim profile image
Theo Ephraim

You’ll probably like varlock.dev - it’s a free open source toolkit that replaces dotenv and solves a lot of these problems.

Collapse
 
teguh_coding profile image
Teguh Coding

Thanks for the suggestion, Theo! Just checked out varlock — the @decorator comments 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 👍