Managing Environment Variables with dotenv: Multi-Environment Files, Defaults, Validation and Secrets

Key takeaways

dotenv loads environment variables from a .env file into process.env. It's the standard way to manage configuration and secrets in Node.js applications.

Introduction

dotenv is a zero-dependency module that loads environment variables from a .env file into process.env. It’s the standard for managing configuration in Node.js apps.

I’ve seen a hardcoded database password sit in a private repo for over a year with nobody thinking twice about it — right up until that repo got shared with a contractor, or a screen got recorded for a demo, or (the actual incident) a git log search for something unrelated turned up the password in a three-year-old commit that had long since been “fixed” in the current file but never scrubbed from history. That’s really the core argument for dotenv: it’s not primarily about convenience, it’s about making sure a secret is never typed into a file that git tracks in the first place, since removing a secret from the current version of a file does nothing about every prior commit that still has it in plaintext.

The Problem

Hardcoded values (bad):

const db = mysql.createConnection({
  host: 'localhost',
  user: 'admin',
  password: 'secret123', // ❌ Exposed in code!
  database: 'myapp',
});

With dotenv (good):

require('dotenv').config();

const db = mysql.createConnection({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD, // ✅ Secret not in code
  database: process.env.DB_NAME,
});

Installation

npm install dotenv

Basic Usage

Create .env file:

# .env
DB_HOST=localhost
DB_USER=admin
DB_PASSWORD=secret123
DB_NAME=myapp
PORT=3000

Load in your app:

// Load as early as possible
require('dotenv').config();

// Now use environment variables
console.log(process.env.DB_HOST);     // 'localhost'
console.log(process.env.PORT);        // '3000'

require('dotenv').config() has to run before anything that reads process.env.* — including, critically, before any of your own modules are imported if they read environment variables at their own module-load time rather than inside a function. This is a genuinely common gotcha: a module that does const dbUrl = process.env.DATABASE_URL at the top level, imported before dotenv.config() runs, captures undefined permanently, and no amount of setting the variable correctly afterward fixes it — the read already happened. “Load as early as possible,” the comment here, really means “load before any other application code that might read process.env,” which in practice usually means the very first line of your entry file.

ES Modules

import 'dotenv/config';

// Or
import dotenv from 'dotenv';
dotenv.config();

console.log(process.env.DB_HOST);

import 'dotenv/config' (the side-effect-only import, no assignment) is the ESM equivalent of the “load as early as possible” concern above, and it’s worth reaching for specifically because ESM import ordering is stricter than a require() call placed wherever convenient — a side-effect import at the very top of the entry file is the idiomatic way to guarantee it runs before any other module’s top-level code, including other imports, since ESM hoists imports to the top regardless of where they’re textually written.

Custom Path

require('dotenv').config({ path: '/custom/path/.env' });

// Or multiple files
require('dotenv').config({ path: '.env.local' });
require('dotenv').config({ path: '.env' });

The two-file pattern here has a real ordering subtlety worth understanding rather than copying blindly: by default, dotenv.config() does not overwrite a variable that’s already set in process.env — the first .config() call to set a given key wins, later calls are no-ops for that key. Loading .env.local first and .env second, as shown, means .env.local’s values take priority (the intended “local overrides” behavior), and reversing that order would silently defeat the whole point of having a separate local-override file — a mistake that doesn’t error, it just quietly loads the wrong values.

Multi-Environment Setup

Development

# .env.development
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
API_URL=http://localhost:3000
LOG_LEVEL=debug

Production

# .env.production
NODE_ENV=production
DB_HOST=prod-db.example.com
DB_PORT=5432
API_URL=https://api.example.com
LOG_LEVEL=error

Load Based on Environment

const path = require('path');
const dotenv = require('dotenv');

const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
dotenv.config({ path: path.resolve(process.cwd(), envFile) });

console.log(`Running in ${process.env.NODE_ENV} mode`);

This pattern’s real-world value shows up specifically once a team has more than one developer or more than one deployment target: instead of every developer editing a single shared .env and stepping on each other’s local database URLs, each environment (development, staging, production) gets its own file, and switching between them is a matter of which value NODE_ENV holds, not manually editing config by hand before every different kind of run. It’s worth noting NODE_ENV itself has to already be set (by the shell, an npm script, or the deployment platform) before this code runs — dotenv can’t read NODE_ENV from inside a .env.${NODE_ENV} file to decide which .env.${NODE_ENV} file to load, that would be circular.

Variable Types

String Values

APP_NAME=MyApp
API_KEY=abc123xyz
console.log(process.env.APP_NAME); // 'MyApp'

The detail worth internalizing before touching any of the type examples below: every single value in process.env is a string, always, with no exceptions — .env has no actual type system, it’s plain text parsed line by line. PORT=3000 doesn’t give you the number 3000, it gives you the string "3000", which is exactly why the Numbers and Booleans sections below need explicit conversion rather than just being able to use the value directly — this trips up a lot of people who expect if (process.env.DEBUG) to behave like a real boolean check, when in reality the string "false" is truthy in JavaScript (it’s a non-empty string), so that comparison silently does the wrong thing unless it’s the explicit === 'true' form shown here.

Numbers

PORT=3000
MAX_CONNECTIONS=100
// Convert to number
const port = parseInt(process.env.PORT, 10);
const maxConn = Number(process.env.MAX_CONNECTIONS);

Boolean Values

DEBUG=true
ENABLE_CACHE=false
const debug = process.env.DEBUG === 'true';
const enableCache = process.env.ENABLE_CACHE === 'true';

JSON Values

# Not recommended, but possible
CONFIG_JSON='{"key":"value","nested":{"prop":true}}'
const config = JSON.parse(process.env.CONFIG_JSON);

“Not recommended, but possible” undersells the actual risk here — a malformed JSON string in a .env file (a missing quote, a trailing comma) doesn’t fail at load time when dotenv reads it, it fails later, at JSON.parse(), with an error that points at the parse call rather than at the actual .env file where the real mistake is. For anything beyond a trivial flat structure, a real config file (config.json, a .js/.ts config module) loaded separately from environment variables is almost always a better fit than trying to cram structured data into a single-line env var string.

Default Values

const {
  PORT = 3000,
  DB_HOST = 'localhost',
  NODE_ENV = 'development',
} = process.env;

console.log(PORT); // Uses 3000 if PORT not set

Destructuring defaults from process.env this way is convenient, but it’s worth being precise about exactly when the default kicks in: JavaScript’s destructuring default only applies when the property is undefined, which for process.env means the variable genuinely isn’t set at all — it does not apply for an empty string. A .env line like PORT= (present but with no value) sets process.env.PORT to "", not undefined, so PORT here would end up as "", not 3000 — a subtle difference between “variable missing” and “variable present but empty” that this shorthand doesn’t distinguish, and one worth checking for explicitly (if (!process.env.PORT)) if an empty-but-present value is a realistic failure mode in your deployment.

Validation

Manual Validation

require('dotenv').config();

const requiredEnvVars = [
  'DB_HOST',
  'DB_USER',
  'DB_PASSWORD',
  'JWT_SECRET',
];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

This kind of fail-fast check at startup is worth taking seriously rather than treating as boilerplate — the alternative is a service that starts up “successfully” but crashes (or, worse, silently misbehaves) the first time it tries to actually use a missing config value, potentially minutes or hours after deploy, with a stack trace that points at wherever undefined first got used rather than at the real root cause. Failing loudly at process start, before the app accepts any traffic, turns a confusing runtime bug into an immediate, obvious deploy failure — genuinely one of the highest-value, lowest-effort reliability improvements a Node service can have.

With envalid

npm install envalid
require('dotenv').config();
const { str, port, num, bool } = require('envalid');

const env = require('envalid').cleanEnv(process.env, {
  NODE_ENV: str({ choices: ['development', 'test', 'production'] }),
  PORT: port({ default: 3000 }),
  DB_HOST: str(),
  DB_PORT: num({ default: 5432 }),
  ENABLE_HTTPS: bool({ default: false }),
});

console.log(env.PORT); // Validated and converted to number

envalid is the more robust version of the manual validation loop above — it solves the type-coercion problem from Section 6 (every env var being a string) and the presence-checking problem from the manual example in one pass, and its errors are considerably more actionable: PORT: port({...}) fails clearly with “PORT must be a valid port number” if someone sets PORT=abc, rather than the manual approach’s parseInt('abc', 10) silently producing NaN and failing somewhere downstream with a much less obvious error. For anything beyond a handful of required string variables, reaching for a validation library like this rather than hand-rolling checks tends to pay for itself quickly once config gets non-trivial.

Security Best Practices

Never Commit .env

# .gitignore
.env
.env.local
.env.*.local

Worth double-checking this pattern against whatever .env.* files a project actually uses — the wildcard here only covers .env, .env.local, and files matching .env.*.local (like .env.development.local), but it does not cover .env.development or .env.production without the .local suffix, the exact files created in the Multi-Environment Setup section earlier. If those per-environment files end up holding real secrets rather than just non-sensitive defaults, they need their own explicit .gitignore entries too — a gap that’s easy to miss since the file naming looks covered by the wildcard at a glance.

Provide .env.example

# .env.example (commit this!)
DB_HOST=localhost
DB_USER=your_db_user
DB_PASSWORD=your_db_password
JWT_SECRET=your_secret_key
PORT=3000

.env.example earns its keep the moment a new developer joins a project or a CI pipeline needs to know what config exists without anyone having to describe it verbally — it’s effectively self-documenting setup instructions that live in version control and stay in sync with the codebase (when discipline holds), rather than a wiki page that inevitably drifts out of date. It’s worth treating updating .env.example as a required part of any change that adds a new environment variable, exactly like updating a schema migration — skipping it means the next person to set up the project hits a confusing “why isn’t this working” moment that a two-line addition to this file would have prevented entirely.

Rotate Secrets Regularly

# Update secrets periodically
JWT_SECRET=new_secret_$(date +%s)

Worth flagging this line as illustrative rather than literal, since it’s a genuine trap if copied as-is: .env files aren’t shell scripts, and dotenv doesn’t execute command substitution — $(date +%s) inside a .env file is parsed as a literal string, not the current Unix timestamp, so the actual value of JWT_SECRET would end up being the text new_secret_$(date up to whatever character dotenv’s parser stops at, not a genuinely rotated secret. Real secret rotation means generating a new value (with something like the crypto.randomBytes snippet in the next section) and writing it into the .env file directly, or better, into whatever secrets manager your deployment platform uses.

Use Strong Secrets

// Generate strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret);

crypto.randomBytes (Node’s cryptographically secure random generator) matters specifically because a secret used for something like signing JWTs needs to be genuinely unpredictable — a weak or guessable JWT_SECRET (a short phrase, a word from a dictionary) means an attacker who can brute-force or guess it can forge valid tokens for any user, which defeats the entire point of using signed tokens for authentication in the first place. 64 random bytes hex-encoded is comfortably beyond what’s practically brute-forceable, which is why this is the recommended way to generate one rather than typing something memorable.

Restrict File Permissions

chmod 600 .env  # Only owner can read/write

This matters more on a shared server than a personal laptop, but it’s cheap enough to be worth doing everywhere: by default, a file’s permissions typically allow other local users on the same machine to at least read it, and a .env full of database credentials and API keys readable by any other account on a shared host is a real, if often-overlooked, attack surface — chmod 600 restricts read/write to the file’s owner only, which is a one-line habit worth having on any server where the .env file actually lives.

Production Deployment

Docker

# Dockerfile
FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Don't copy .env - use environment variables
CMD ["node", "index.js"]
# docker-compose.yml
version: '3'
services:
  app:
    build: .
    environment:
      DB_HOST: postgres
      DB_USER: admin
      DB_PASSWORD: ${DB_PASSWORD}
    env_file:
      - .env

Notice the Dockerfile explicitly avoids COPY .env . (it’s not even shown as an option here) — baking a .env file into a Docker image means the secrets it contains become part of the image itself, readable by anyone who can pull or inspect that image (docker history, extracting a layer), which is a meaningfully bigger exposure than a compromised server, since images often get pushed to a registry, cached in CI, and pulled by multiple environments. docker-compose.yml’s environment/env_file keys inject variables into the running container at start time instead, which is the correct boundary — the values exist in the running process’s environment, never baked into the image layers that get distributed and cached.

Heroku

# Set via CLI
heroku config:set DB_HOST=postgres.heroku.com
heroku config:set DB_PASSWORD=secret

# Or via dashboard
# Settings > Config Vars

Vercel

# .vercel.json (don't commit secrets!)
# Or use Vercel dashboard

vercel env add DB_PASSWORD

AWS/GCP

Use secrets managers:

const { SecretsManagerClient } = require('@aws-sdk/client-secrets-manager');

async function getSecret(secretName) {
  const client = new SecretsManagerClient({ region: 'us-east-1' });
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretName })
  );
  return JSON.parse(response.SecretString);
}

Managed secrets managers solve problems .env files structurally can’t, which is worth naming directly rather than treating this as a lateral alternative: audit logging (who accessed which secret, when), automatic rotation without redeploying the app, fine-grained per-secret access control via IAM rather than “whoever can read the server’s filesystem gets everything,” and encryption at rest managed by the cloud provider rather than relying on file permissions alone. The FAQ’s “many platforms prefer native environment variables” point extends naturally here — for anything handling genuinely sensitive data at scale, a secrets manager is the more defensible choice than .env files even in production, not just a nice-to-have.

Real-World Example

// config.js
require('dotenv').config();

const config = {
  app: {
    name: process.env.APP_NAME || 'MyApp',
    port: parseInt(process.env.PORT, 10) || 3000,
    env: process.env.NODE_ENV || 'development',
  },
  db: {
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT, 10) || 5432,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    name: process.env.DB_NAME,
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '1h',
  },
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT, 10) || 6379,
  },
  email: {
    host: process.env.EMAIL_HOST,
    port: parseInt(process.env.EMAIL_PORT, 10) || 587,
    user: process.env.EMAIL_USER,
    password: process.env.EMAIL_PASSWORD,
  },
};

// Validate critical vars
if (!config.jwt.secret) {
  throw new Error('JWT_SECRET is required');
}

module.exports = config;

Centralizing every process.env read into one config.js module, rather than scattering process.env.X calls throughout the codebase, is a pattern worth adopting deliberately — it means every default value, every type conversion (parseInt for ports), and every validation check lives in exactly one place instead of being duplicated (and potentially inconsistent) everywhere a given variable is used, and it gives the rest of the app a clean, already-typed config object to import instead of reaching into process.env directly. This is also what makes the TypeScript version in the next section actually enforceable — a scattered set of raw process.env reads throughout a codebase can’t be given a single, coherent type the way one central config object can.

// app.js
const config = require('./config');
const express = require('express');

const app = express();

app.listen(config.app.port, () => {
  console.log(`${config.app.name} running on port ${config.app.port}`);
});

TypeScript Integration

// env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      PORT: string;
      DB_HOST: string;
      DB_USER: string;
      DB_PASSWORD: string;
      JWT_SECRET: string;
    }
  }
}

export {};

Declaring every field here as a non-optional string (rather than string | undefined) is convenient but worth understanding as a promise to the type checker that isn’t actually enforced at runtime — TypeScript’s built-in ProcessEnv type already types every value as string | undefined by default, since that’s genuinely accurate (a variable might not be set), and this augmentation overrides that with a stronger guarantee the code itself doesn’t verify. It only becomes true if something like the manual validation loop or envalid from Section 8 actually runs and throws before this type is relied on elsewhere — without that runtime check, TypeScript will happily let code treat process.env.JWT_SECRET as a guaranteed string even when it’s actually undefined, silently reintroducing the exact class of bug the type declaration was meant to prevent.

// config.ts
import 'dotenv/config';

interface Config {
  port: number;
  database: {
    host: string;
    user: string;
    password: string;
  };
}

const config: Config = {
  port: parseInt(process.env.PORT, 10),
  database: {
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  },
};

export default config;

Combining this pattern with a runtime validator like envalid closes the gap the previous note raises — envalid’s cleanEnv return value can itself be typed accurately (it reflects what was actually validated, not an unchecked ambient declaration), which is a meaningfully sounder foundation for a Config interface than trusting an ambient ProcessEnv augmentation that was never actually checked against real values at runtime.

Troubleshooting

Variables Not Loading

// Debug mode
require('dotenv').config({ debug: true });

// Check if .env exists
const fs = require('fs');
if (!fs.existsSync('.env')) {
  console.error('.env file not found!');
}

{ debug: true } is genuinely the fastest way to diagnose “why isn’t my variable loading” — it logs exactly which file dotenv found (or didn’t), which keys it parsed, and any parse errors it hit along the way, which is far more informative than guessing. The most common root cause behind “variables not loading” in my experience isn’t actually a dotenv bug — it’s the load-order issue from Section 2 (some other module already read process.env before .config() ran), a .env file that exists but isn’t in the working directory the process actually started from (dotenv resolves paths relative to process.cwd(), not the file that calls .config()), or, as covered next, an existing environment variable silently taking precedence over the .env file’s value.

Override Existing Variables

// Force override (not recommended)
require('dotenv').config({ override: true });

This ties directly back to the “not overwriting existing variables” default mentioned in Section 5 — it’s the same behavior showing up as a debugging headache instead of a feature: if a shell, a CI runner, or a platform like Docker’s environment: block already set DB_HOST before the Node process starts, dotenv’s default behavior silently keeps that pre-existing value and ignores whatever .env says, which looks exactly like “my .env file isn’t working” from the outside. override: true fixes the symptom but is rightly flagged “not recommended” as a default — for local development it’s usually more correct to figure out why a stale shell-exported variable is shadowing the .env file (a forgotten export in a shell profile is a common culprit) than to force .env to always win.

Encoding Issues

// Specify encoding
require('dotenv').config({ encoding: 'utf8' });

Do you still need the dotenv package?

Node.js can now read .env files itself: node --env-file=.env app.js (added in Node 20.6) and process.loadEnvFile() (Node 21.7, backported to 20.12) load the same KEY=value format without a dependency. For a service that runs on a current Node version and only needs one file loaded at startup, the built-in flag is enough and removes one package from the supply chain. The dotenv package still earns its place when you need programmatic control — loading several files in a specific precedence order as shown above, choosing a path at runtime, or supporting older Node versions and other runtimes such as test runners that start their own processes.

Whichever loader you use, the rules that matter are the same: keep .env out of git and ship a .env.example instead, validate required variables at startup so a missing key fails fast, and in production let the platform inject the variables rather than copying a .env file onto the server. The 12-Factor App config chapter is the short reference behind that last rule.