Dotenv Cheat Sheet
.env file format, variable interpolation, precedence, framework loading, and security best practices for environment-based configuration.
A .env file is a flat list of KEY=value pairs loaded into the process environment at startup. The format has no real specification, but the dotenv/dotenvx conventions are the de facto standard. Treat it as configuration, never as storage, and keep secrets out of version control.
File format
The grammar is intentionally tiny: one variable per line, optional quotes, # for comments. Values are always strings — true and 42 are not booleans or numbers until the application parses them.
# .env — comments start with #, no quotes required
APP_NAME=genithora
PORT=3000
DEBUG=false
LOG_LEVEL=info
# Quotes preserve whitespace and special chars
GREETING="Hello, world"
DATABASE_URL='postgres://user:p@ss@db:5432/app'
# Empty value
EMPTY_VAR=
| Rule | Behavior |
|---|---|
KEY=value | No spaces allowed around = |
KEY="value" | Double quotes allow variable expansion |
KEY='value' | Single quotes are literal — no $VAR expansion |
KEY= | Empty string, distinct from unset |
Lines starting with # | Comments |
export KEY=value | Mark for export (POSIX-style; loader usually ignores) |
| Values are always strings | Coerce in code: Number(process.env.PORT) |
Variable interpolation
Most loaders expand $VAR and ${VAR} only inside double quotes. Use ${VAR:-default} for a fallback and ${VAR:?error} to fail fast when a required secret is missing. The reference implementation is dotenv-expand.
BASE_URL="https://api.example.com"
CALLBACK_URL="${BASE_URL}/auth/callback"
# Default if unset
LOG_LEVEL="${LOG_LEVEL:-info}"
# Required — fails to load if missing
DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}"
# Nested reference
DATABASE_URL="postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/${DB_NAME}"
Multi-line values
Quoted values may span multiple lines. Newlines are preserved verbatim, which is the standard way to embed PEM keys and JSON configs.
# PEM private key inside a .env file
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"
# JSON snippet
FEATURE_FLAGS='{"beta":true,"max":42}'
To read these safely in Node:
import fs from "node:fs";
const key = process.env.PRIVATE_KEY;
// .trim() because the surrounding quotes left a trailing newline
const pem = key.replace(/\\n/g, "\n").trim();
Precedence rules
Loaders fill in variables from the existing process environment first, then from .env* files. The exact stack depends on the framework, but the principle is the same: more specific files override general ones, and the OS environment wins last.
| Source | Priority | When it loads |
|---|---|---|
| OS environment | Highest | Already present when the app starts |
.env.local | ↑ | Local developer overrides — gitignored |
.env.production / .env.development | ↑ | Per-mode values |
.env | Lowest | Default committed template |
| Framework | Files checked | Public prefix |
|---|---|---|
| Next.js | .env.local, .env.production, .env.development, .env | NEXT_PUBLIC_ |
| Vite | .env.local, .env.[mode], .env | VITE_ |
| Create React App | same as Vite | REACT_APP_ |
| Nuxt | .env, .env.local, .env.[mode] | NUXT_PUBLIC_ |
Next.js also loads .env.test for the test runner, and Vite prefixes only the listed variables into the client bundle — the rest stay server-side.
Loading from Node.js
dotenv and its successors (dotenvx, node --env-file=.env since Node 20.6) handle loading, quoting, and expansion. Always call the loader before any module reads process.env.
// Modern Node (20.6+) — no library required
node --env-file=.env --env-file=.env.local app.js
// Classic approach with dotenv
import "dotenv/config"; // reads .env
import { config } from "dotenv";
config({ path: ".env.local", override: true });
For TypeScript projects, validate the loaded values against a schema so the app fails to start instead of throwing at runtime:
import { z } from "zod";
import { config } from "dotenv";
const schema = z.object({
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["true", "false"]).transform((v) => v === "true"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Security best practices
The point of .env is to keep secrets out of source control and out of client bundles. Most leaks come from committing a real .env, copying one into a Docker image, or accidentally exposing it through a public NEXT_PUBLIC_ prefix.
| Practice | Why it matters |
|---|---|
Add .env* to .gitignore | Prevent accidental commits of secrets |
Commit a .env.example with placeholders | Onboards teammates without leaking data |
| Validate required variables on boot | Fail fast rather than mid-request |
| Never log full env objects | Logs end up in aggregators and tickets |
| Use a secret manager in production | .env files are fine for dev, Vault/AWS SSM/Doppler for prod |
| Strip secrets from Docker images | COPY a .env.production from a secret, not the repo |
Don't prefix secrets with NEXT_PUBLIC_ / VITE_ | Public vars are bundled into the client |