Dotenv Cheat Sheet

.env file format, variable interpolation, precedence, framework loading, and security best practices for environment-based configuration.

Data Formats
dotenv
env
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.

bash
# .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=
Table
RuleBehavior
KEY=valueNo 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=valueMark for export (POSIX-style; loader usually ignores)
Values are always stringsCoerce 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.

bash
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.

bash
# 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:

js
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.

Table
SourcePriorityWhen it loads
OS environmentHighestAlready present when the app starts
.env.localLocal developer overrides — gitignored
.env.production / .env.developmentPer-mode values
.envLowestDefault committed template
Table
FrameworkFiles checkedPublic prefix
Next.js.env.local, .env.production, .env.development, .envNEXT_PUBLIC_
Vite.env.local, .env.[mode], .envVITE_
Create React Appsame as ViteREACT_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.

js
// 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:

ts
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.

Table
PracticeWhy it matters
Add .env* to .gitignorePrevent accidental commits of secrets
Commit a .env.example with placeholdersOnboards teammates without leaking data
Validate required variables on bootFail fast rather than mid-request
Never log full env objectsLogs 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 imagesCOPY a .env.production from a secret, not the repo
Don't prefix secrets with NEXT_PUBLIC_ / VITE_Public vars are bundled into the client

References