Environment variables keep secrets out of code — API_KEY=sk-... lives in .env (gitignored), not in const API_KEY = "sk-" in git. This complete guide covers what env vars are, .env anatomy (quotes, comments, multiline), how to load in Node/Python/Docker, security (never commit, rotate leaks), advanced expansion and precedence, and validation — from local to production.
- What:
KEY=VALUEin process env — e.g.,PORT=3000,DATABASE_URL=postgres://...→process.env.PORTin Node,os.getenv("PORT")in Python. Separate config from code per 12-Factor config. - .env file: one
KEY=VALUEper line, noexport, quote values with spaces or#,\nfor multiline private keys, full-line# commentonly. Example:DATABASE_URL="postgres://user:pass@host/db". - Load: Node
npm i dotenv && require('dotenv').config()→process.env; Pythonpip install python-dotenv && load_dotenv()→os.getenv; Dockerdocker run --env-file .envor Composeenv_file: [.env]. System env wins — dotenv won't overwrite existing by default. - Security: add
.envto.gitignore, commit.env.example(keys without values); if leaked, rotate immediately (revoke, new key) —git rm --cachedisn't enough because git history retains. Prod: use Vault/AWS Secrets Manager/Doppler, not.envon disk. - Advanced: expansion
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/dbviadotenv-expand, default${PORT:-3000},.env.localoverrides.env, Vite/CRA filterVITE_/REACT_APP_to client. Validate via environment variable tools (lint and convert .env safely).
What Are Environment Variables — 12-Factor Config
An environment variable is a named value in a process's environment — inherited from its parent shell or set by the runtime. Code reads it at runtime instead of hard-coding: process.env.PORT (Node), os.environ["PORT"] (Python), $PORT (shell). Benefit per 12-Factor App: Config — store config in the environment, not the codebase — so same image runs dev (PORT 3000, DB localhost), staging (PORT 4000, DB staging), prod (PORT 80, DB managed) by changing env, not branch.
Bad: const API_KEY = "sk-1234" in config.js — in git, leaked via GitHub, hard to rotate. Good: API_KEY=sk-1234 in .env (gitignored) → process.env.API_KEY. Rotating is editing file or secret store, not code.
Benefits: no secrets in git (audit), easy rotate, same artifact per env (12-Factor build once), and CI can inject per-deploy. See 12-Factor overview.
Process vs File
Environment is per-process memory (envp), not file. .env is just a convention — a file that dotenv parses and copies into that memory at boot. System env (from shell export PORT=3000 or CI secrets) is already there — dotenv won't overwrite it unless override:true.
.env Anatomy — KEY=VALUE, Quoting, Comments, and Multiline
# .env — no export prefix, UPPER_SNAKE_CASE
NODE_ENV=production
PORT=3000
DATABASE_URL="postgres://user:pass@db.example.com:5432/myapp" # quotes because : and /
API_KEY=sk-proj-abc123 # no spaces, no quotes needed
# Full-line comment only — no inline # inside unquoted value
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvg...\n-----END PRIVATE KEY-----"
# Multiline private key must be quoted with \n, not real newline
EMPTY_VALUE=
# KEY with no value → empty string
- No
export: shell usesexport PORT=3000,.envuses barePORT=3000—exportis ignored by many parsers and breaks Windows. - Quotes: wrap if value has spaces,
#, or\n.GREETING="Hello world"needs quotes;PORT=3000doesn't. Double quotes allow\n→ newline for private keys; single quotes may be literal. - Comments: full-line
# commentonly;KEY=val # commentmay treat# commentas part of value if not quoted — avoid inline. - Multiline:
PRIVATE_KEY="-----BEGIN...\nMII..."with quoted\n— many parsers join. Don't split without quotes. - No
exportand no spaces around=:PORT = 3000with spaces may be parsed asPORTwith value3000with leading space — avoid.
Naming: UPPER_SNAKE_CASE by convention — DATABASE_URL, REDIS_URL, STRIPE_SECRET_KEY, JWT_SECRET. Commit .env.example with keys and empty or dummy values: DATABASE_URL=postgres://user:pass@host/db as guide but not real secret.
.env vs .env.example vs .env.local
.env is the file you load (gitignored). .env.example is the template committed (no real secrets) — new devs copy cp .env.example .env and fill. Frameworks add ladder: .env → .env.local (gitignored local override) → .env.development / .env.production → system env — later overrides earlier. Vite filters VITE_ only to client, CRA REACT_APP_ — see Vite env.
Loading — Node, Python, Docker, and System (Precedence Matters)
Node.js — dotenv:
npm i dotenv
// top of entry:
require('dotenv').config() // reads .env in cwd by default
console.log(process.env.PORT) // "3000"
// custom path: require('dotenv').config({ path: '.env.local' })
Python — python-dotenv:
pip install python-dotenv
from dotenv import load_dotenv
import os
load_dotenv() # reads .env
print(os.getenv("PORT")) # "3000"
Docker — env_file vs -e:
docker run --env-file .env -p 3000:3000 myapp # file
docker run -e PORT=3000 -e API_KEY=sk-... myapp # explicit
# Compose:
# compose.yml: services: web: env_file: [.env] # plus environment: [PORT=3000] overrides file
System — export: export PORT=3000 in shell (~/.bashrc or CI env:) → printenv | grep PORT. Dotenv won't override existing system var unless override:true in Node (config({override:true})) — keep default (system wins) so prod injection via CI/K8s secret beats local .env.
Precedence: system env → .env.local → .env → .env.example (lowest). This is why committed .env.example never overwrites your local .env.
Docker Compose Gotcha — env_file Path
env_file: .env is relative to compose.yml not cwd. .env next to compose.yml is loaded automatically for variable substitution in the file itself — see Compose env vars.
Security — .gitignore, If Leaked Rotate, Prod Secrets Not in .env
Never commit .env: Top of .gitignore:
.env
.env.local
.env.production
Commit .env.example only. Verify before push: git status should never show .env; if it does, git rm --cached .env && echo .env >> .gitignore && git commit now — but history still has secret, so rotate anyway.
If leaked (pushed to GitHub, pasted in Slack): 1. Generate new secret in dashboard (new Stripe key, new DATABASE_URL password). 2. Revoke old immediately. 3. Don't rely on git filter-branch or BFG — secret is already scraped by bots within seconds. Rotate is the only fix. Add pre-commit hook via gitleaks or detect-secrets to fail commit if high-entropy string detected.
Prod: not .env on disk. Use Vault, AWS Secrets Manager, Doppler, or K8s Secret (base64, encryption at rest) — injected as env at runtime via sidecar, not file on host. .env is for dev/test convenience; prod reads from secret store or CI env. See AWS Secrets Manager and Vault.
Log Redaction
Never log process.env whole — console.log(process.env.DATABASE_URL) leaks to CloudWatch visible to support. Redact: log DATABASE_URL=postgres://***:***@host/db.
Advanced — Expansion, Defaults, Multiline, and Precedence
# Expansion via dotenv-expand (Node)
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/mydb
# DB_USER=root DB_PASS=s3cret → expands on load
# Defaults:
PORT=${PORT:-3000} # if PORT not set, default 3000 (with dotenv-expand)
# Multiline (quoted
):
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvg...\n-----END PRIVATE KEY-----"
# Without plugin, ${VAR} stays literal — need dotenv-expand or shell export
Expansion requires dotenv-expand (Node) or native shell export — plain dotenv does not expand ${VAR} by default. Default syntax ${PORT:-3000} is shell-style — only expanded if plugin enabled. Without expansion, string stays literal and DB connection fails. See dotenv-expand.
Precedence: system env → .env.local → .env → .env.example (lowest). Dotenv by default does not overwrite already set system var — system wins, which is correct for CI injecting prod secrets over local .env. Use override:true only in tests.
Ladder: .env (default) → .env.local (gitignored local override) → .env.development / .env.production (per env) → system. Vite/CRA filter prefixes: VITE_ only leaks to browser, others stay server — per Vite. Don't put STRIPE_SECRET in VITE_ — it ships to client.
Validation and Tools — Fail Fast, Not at Runtime
Don't trust raw process.env.PORT is number or URL — validate on boot and crash early with message, not NaN in prod:
// envalid (Node) or Zod/Joi
import { cleanEnv, str, num, url } from 'envalid'
export const env = cleanEnv(process.env, {
PORT: num({ default: 3000 }),
DATABASE_URL: url(),
API_KEY: str(),
}) // throws if missing/wrong type at boot
Convert: JSON ↔ .env, YAML ↔ .env — ensure no unescaped quotes inside value (e.g., PASSWORD="a"b" breaks parse). Generate: scaffolding from schema avoids typos in KEY names — validate and convert safely via our environment variable tools (lint high-entropy secrets, convert JSON/YAML to .env with quoting).
Pre-commit Hook Example
# .githooks/pre-commit or husky
gitleaks detect --source . --verbose
# or: detect-secrets audit .secrets.baseline
Hook fails commit if .env contains sk-proj-... entropy — catch before push. Add to CI too.
Common Pitfalls — Quotes, Spaces, and % in Values
- Unquoted # inside value:
API_KEY=sk#123without quotes → parser treats#123as comment → valuesk. Fix:API_KEY="sk#123". - Spaces around =:
PORT = 3000with spaces → some parsers include leading space in value" 3000"→parseInt(" 3000")=3000passes butURL " https://..."fails. Avoid spaces. - Windows line endings:
\r\nleaves\rin value on Linux — configure.gitattributes text eol=lf. - Export prefix in .env:
export PORT=3000breaks Windows and some dotenv versions — keep bare. - Inline comment after value:
PORT=3000 # prod→ value"3000 # prod"if quoted, else"3000"plus comment — avoid inline, use full line.
Environments — .env Ladder, 12-Factor, and CI Injection
Local ladder: .env (committed example) → .env.local (gitignored, your overrides) → .env.development / .env.production (per-env, often committed without secrets) → system env (CI, Docker, K8s Secret). System wins so CI's DATABASE_URL overrides local .env without editing files. For Vercel/Netlify, set dashboard env, not repo file. For GitHub Actions: env: DATABASE_URL: ${{ secrets.DATABASE_URL }}.
Conventional loading order per dotenv docs and CRA env: check their precedence to avoid .env.production leaking to dev.
# CI injects, app reads process.env, no file on host
# GitHub Actions:
# env:
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Docker: docker run -e DATABASE_URL --env-file not used in prod
# K8s: envFrom: [{ secretRef: { name: app-secret } }]
No .env on disk in prod means no file to leak via /static, backup, or image layer.
Validation Deep — Fail Fast With Schema
Missing PORT should crash at boot with clear message, not NaN in prod at 3am. Enforce:
// envalid (throws at import if wrong)
import { cleanEnv, str, num, url, host } from 'envalid'
export const env = cleanEnv(process.env, {
NODE_ENV: str({ choices: ['development','test','production'] }),
PORT: num({ default: 3000 }),
DATABASE_URL: url(),
REDIS_URL: url({ default: 'redis://localhost:6379' }),
})
// Zod alternative:
import { z } from 'zod'
const schema = z.object({ PORT: z.coerce.number().int().min(1024), DATABASE_URL: z.string().url() })
export const env = schema.parse(process.env)
Validate length, URL, enum — fail visible. Add to tests: PORT="" npm test should throw.
Conversion and Tooling — JSON ↔ .env ↔ YAML Without Breaking Quotes
Converting {"PORT":3000,"URL":"https://example.com"} to .env naively as PORT=3000\nURL=https://example.com loses quoting if URL has # or . Tools must quote values with spaces, #, or newline. Our environment variable tools lint high-entropy secrets, convert JSON/YAML to .env with correct quoting, and detect export prefix or inline # mistakes before commit.
Example Conversions
# JSON {"API_KEY":"sk#123","PORT":3000} → .env
API_KEY="sk#123" # quoted because #
PORT=3000
# .env → Docker env-file is direct: docker run --env-file .env
Framework Filters — VITE_ vs NEXT_ vs REACT_APP_
Client bundlers filter which env leaks to browser bundle via prefix — server secrets must not use that prefix:
- Vite:
VITE_API_URLexposed,API_KEYstays server. Docs: Vite env. - Next.js:
NEXT_PUBLIC_exposed, others server. See Next.js env. - CRA:
REACT_APP_exposed. Docs: CRA env.
Putting STRIPE_SECRET_KEY in VITE_STRIPE_SECRET ships secret to client JS — viewable via DevTools. Audit bundle: grep -r VITE_ dist/.
Kubernetes and CI Patterns — 12-Factor in Production
In K8s, .env file is an anti-pattern — use native Secret and ConfigMap:
apiVersion: v1
kind: Secret
metadata: { name: app-secret }
type: Opaque
data: { DATABASE_URL: cG9zdGdyZXM6Ly8= } # base64, encryption at rest if enabled
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef: { name: app-secret } # or configMapRef
- configMapRef: { name: app-config }
CI injection: GitHub Actions env: DATABASE_URL: ${{ secrets.DATABASE_URL }} → process.env.DATABASE_URL without file. Docker Swarm: docker stack deploy --with-registry-auth with secrets:. See GitHub Secrets and K8s Secret.
CI vs Local — Same Code, Different Source
Local: load_dotenv() reads .env. Prod: no .env, CI injects. Code path os.getenv("PORT") or "3000" stays identical — only source changes. This is 12-Factor's point: config varies via env, not branch.
Secrets Management Compared — .env File vs Vault vs Cloud Secret Store
| Method | Best for | Rotation | Audit |
|---|---|---|---|
| .env file (gitignored) | Local dev/test convenience | Manual edit + restart | None |
| dotenv + .env.example | Onboarding new devs | Template only | None |
| AWS Secrets Manager / Doppler / Vault | Prod, team shared, rotation, audit | Auto rotation + revoke | CloudTrail, versioned |
| K8s Secret / ConfigMap | K8s prod | Recreate + rollout | etcd + RBAC |
Rule: .env for dev, secret store for prod and team shared. Check Vault vs Secrets Manager for choice — Vault for self-hosted rotation, Secrets Manager for AWS-native IAM.
Complete .env.example Template — Copy This
# .env.example — committed, no real secrets, copy to .env and fill
NODE_ENV=development # production | development | test
PORT=3000
DATABASE_URL=postgres://user:pass@host:5432/db
REDIS_URL=redis://localhost:6379
API_KEY=sk-proj-... # from dashboard, not committed
JWT_SECRET=change-me-32-chars
# Optional: PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END"
Keep dummy values but valid format so envalid url() passes on .env.example loaded in CI dry-run. Document where each value comes from in README.
git clone repo && cd repo
cp .env.example .env # fill with dev secrets from 1Password/Doppler
npm i && npm run dev # dotenv loads .env → process.env.PORT
git status # should not show .env
If git status shows .env, stop — fix .gitignore before first commit.
Common Questions — Export, Source, direnv, and Shell vs File
Do I need export in .env? No — .env is bare KEY=VALUE. export is shell syntax (export PORT=3000 in ~/.bashrc). Some loaders tolerate export but Windows and python-dotenv may keep it as part of key — avoid.
source .env? Works for simple KEY=VALUE without quotes needing shell parsing, but PRIVATE_KEY="...\n..." or # comment handling differs — prefer dotenv parser, not shell source.
direnv? direnv auto-loads .envrc on cd — stronger than .env because it exports to shell for any command, not just your app. Use .envrc with dotenv .env if you need env for make, psql outside Node.
Debugging — Why PORT Is Still Undefined
- File location:
dotenv.config()looks in cwd (where you runnode), not file location. Run from project root or passpath: __dirname + '/.env'. - Cached after import:
process.envis read at import time — changing.envrequires restart (nodemon helps). - Name typo:
DATBASE_URLvsDATABASE_URL— validation catches.
From Env to Deploy — Vercel, Netlify, and Docker Compose Variable Substitution
Vercel dashboard → Settings → Environment Variables → add per env (Production/Preview/Development) — no file upload. Next.js on Vercel reads process.env at build for NEXT_PUBLIC_ and runtime for server vars. Netlify similar via Site settings. See Vercel env and Netlify env.
Docker Compose substitution: compose.yml with ${PORT:-3000}:3000 is templated by .env next to compose file at compose up time — per Compose env. This is file-to-file templating, not container env_file — don't confuse ${VAR} in yaml (host substitution) vs environment: [VAR] (container).
CI injects DATABASE_URL via secrets, app reads process.env.DATABASE_URL, no file on host — so even if host is compromised, no file to cat. This is why Vault/Secrets Manager beats scp'ing .env via sftp.
Checklist — Before You Push, Copy This
1) .gitignore has .env, .env.local
2) .env.example exists, no real secrets
3) git status does not show .env
4) app boots: PORT is number, DATABASE_URL is url (envalid throws if wrong)
5) gitleaks detect --source . passes
Keep this 5-line check as CONTRIBUTING.md step — new contributors pass it on first PR.
Bonus: keep .env.example versioned like code — when you add REDIS_URL, bump example and README, not just local .env — so team stays synced without leaking values.
Version your .env.example — v1 with PORT, v2 adds REDIS_URL — so code review catches missing env like missing API key before runtime.
Keep a single source for defaults: PORT=${PORT:-3000} in .env with dotenv-expand beats process.env.PORT || 3000 scattered in 5 files.
Tip: dotenv loads synchronously at require time — place require('dotenv').config() before any import config from './config' that reads process.env, or config sees empty.
Copy working .env.example block as template — one correct template reused beats four hand-typed variants with different DATABASE_URL typos.
Keep .env.example and README env section in sync — when you add REDIS_URL to code, add it to example and docs in same PR.
Version your .env.example like code — v1, v2 — so git diff shows which key was added when DATABASE_URL changed.
Validate early, log redacted — fail fast at boot, not at 3am in production.
Frequently Asked Questions
What is a .env file?
A file of KEY=VALUE lines (no export) that dotenv-style loaders parse into process env at boot — e.g., PORT=3000 → process.env.PORT. One per env, gitignored, with .env.example committed as template.
Should I commit .env to git?
No — add .env to .gitignore and commit .env.example (keys without values). Committed .env leaks secrets to history and bots within seconds; rotate if leaked, don't just rm.
How do I use .env in Node.js?
npm i dotenv then top of entry require('dotenv').config() (or import 'dotenv/config') → process.env.KEY. Custom path: config({path: '.env.local'}). System env already set won't be overwritten unless override:true.
How do I use .env in Python?
pip install python-dotenv → from dotenv import load_dotenv; load_dotenv(); os.getenv("PORT"). .env in project root by default. See python-dotenv.
Can .env values reference other variables?
Only with plugin like dotenv-expand (Node) or shell export — plain dotenv keeps ${VAR} literal. With expand, DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/db expands. Default syntax ${PORT:-3000} also needs expand.
What's the difference between .env and environment variables?
Environment variables are live per-process memory (printenv). .env is a file convention that a loader copies into that memory at boot for convenience. In prod, inject via system env or secret store, not file.