Here is a pattern that happens every single day: a developer asks Claude or Copilot to "set up a database connection" and gets back perfectly working code with the password right there in the file. They commitWhat is commit?A permanent snapshot of your staged changes saved in Git's history, identified by a unique hash and accompanied by a message describing what changed. it, push it, and now their database credentials are in their git history permanently. The .env file pattern exists specifically to prevent this, it gives secrets a home that git never touches.
What dotenvWhat is dotenv?An npm package that reads a .env file and loads its key-value pairs into process.env at startup. does
The dotenv package reads a .env file in your project root and injects each key-value pair into process.env before your app runs. One call at the top of your entry file, and the restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. of your code reads process.env as if you had exported everything by hand.
The AI pattern vs the correct pattern
// What Copilot generates - secrets baked into source code
const stripe = require('stripe')('sk_live_4eC39HqLyjWDarjtT1zdp7dc');
const db = require('pg').Pool({
connectionString: 'postgres://admin:P@ssw0rd!@db.example.com:5432/prod'
});
// What you should actually write - secrets loaded from .env
require('dotenv').config();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const db = require('pg').Pool({
connectionString: process.env.DATABASE_URL
});process.env reads and put the real values in your .env file.Setting it up
npm install dotenvCreate a .env file in your project root (next to package.json):
# .env - never commit this file
PORT=3000
DATABASE_URL=postgresql://localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_1234567890
JWT_SECRET=a-long-random-string-here
NODE_ENV=developmentThen load it at the very top of your main file:
// CommonJS (index.js or server.js)
require('dotenv').config();
// ES Modules
import 'dotenv/config';
// Now process.env is populated
const port = process.env.PORT; // '3000'
const dbUrl = process.env.DATABASE_URL;.env file format rules
The format is straightforward but has a few tricky edges that trip people up:
# Lines starting with # are comments
KEY=value
KEY_WITH_SPACES="hello world" # Quotes required for values with spaces
# No spaces around the equals sign
WRONG = value # dotenv will NOT parse this correctly
RIGHT=value # Correct
# Multiline values need double quotes
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAK...
-----END RSA PRIVATE KEY-----"
# Reference other variables (dotenv-expand package)
BASE_URL=https://api.example.com
USERS_URL=${BASE_URL}/users| Rule | Correct | Incorrect |
|---|---|---|
No spaces around = | KEY=value | KEY = value |
| Quote values with spaces | MSG="hello world" | MSG=hello world |
Comments with # | # This is a comment | Works on its own line |
| Empty values | KEY= | Sets to empty string |
| No export keyword | KEY=value | export KEY=value (not needed) |
Multiple .env files
As projects grow, you will want different configs for different contexts. Most frameworks follow this convention:
| File | Purpose | Commit to git? |
|---|---|---|
.env | Shared defaults | No |
.env.local | Your personal overrides | No |
.env.development | Dev-specific values | Optional |
.env.test | Test environment values | Optional |
.env.production | Production defaults | No |
.env.example | Template for teammates | Yes |
When multiple files are loaded (as frameworks like Next.js and Vite do automatically), more specific files override less specific ones. Your .env.local always wins over .env.
Protect your secrets in git
The first thing to do after creating a .env file is add it to .gitignore:
# Environment variables - never commit these
.env
.env.local
.env.*.local
.env.production
# But DO commit the example file
!.env.exampleThen create a .env.example that documents every variable your project needs, with safe placeholders instead of real secrets:
# .env.example - commit this, share it with your team
PORT=3000
DATABASE_URL=postgresql://localhost:5432/mydb
STRIPE_SECRET_KEY=
JWT_SECRET=
NODE_ENV=developmentWhen a new teammate clones the repo, they copy .env.example to .env and fill in the real values. No secrets travel through git.
.env file, changing .gitignore afterward will not remove it from history. You must rotate (invalidate and replace) every leaked credential immediately, then use git filter-repo to scrub the file from history. The secret should be considered compromised the moment it was pushed.Debugging dotenvWhat is dotenv?An npm package that reads a .env file and loads its key-value pairs into process.env at startup.
If your variables are not loading, dotenv's return value tells you why:
const result = require('dotenv').config();
if (result.error) {
console.error('Failed to load .env:', result.error.message);
// Common causes: .env file not found, wrong working directory
} else {
console.log('Loaded variables:', Object.keys(result.parsed));
}Common dotenv issues:
| Problem | Cause | Fix |
|---|---|---|
Variables are undefined | dotenv loaded after code that reads them | Move require('dotenv').config() to the very first line |
.env file not found | Running from wrong directory | Check process.cwd() or use { path: '.env' } with absolute path |
| Values have quotes in them | Using KEY="value" with some parsers | Remove quotes if the value has no spaces |
| Changes not taking effect | Process not restarted | Restart your dev server after editing .env |
Quick reference
| Task | Code / Command |
|---|---|
| Install dotenv | npm install dotenv |
| Load in CommonJS | require('dotenv').config() |
| Load in ESM | import 'dotenv/config' |
| Load custom path | require('dotenv').config({ path: '.env.staging' }) |
| Add to gitignore | .env and .env.*.local |
| Document variables | Create .env.example with empty values |
| Debug loading | Check require('dotenv').config().error |