All Tools View Categories Blog About Contact Privacy

The Complete Guide to Environment Variables (.env Files)

The Complete Guide to Environment Variables (.env Files)

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.

TL;DR — Environment Variables (.env):
  • What: KEY=VALUE in process env — e.g., PORT=3000, DATABASE_URL=postgres://...process.env.PORT in Node, os.getenv("PORT") in Python. Separate config from code per 12-Factor config.
  • .env file: one KEY=VALUE per line, no export, quote values with spaces or #, \n for multiline private keys, full-line # comment only. Example: DATABASE_URL="postgres://user:pass@host/db".
  • Load: Node npm i dotenv && require('dotenv').config()process.env; Python pip install python-dotenv && load_dotenv()os.getenv; Docker docker run --env-file .env or Compose env_file: [.env]. System env wins — dotenv won't overwrite existing by default.
  • Security: add .env to .gitignore, commit .env.example (keys without values); if leaked, rotate immediately (revoke, new key) — git rm --cached isn't enough because git history retains. Prod: use Vault/AWS Secrets Manager/Doppler, not .env on disk.
  • Advanced: expansion DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/db via dotenv-expand, default ${PORT:-3000}, .env.local overrides .env, Vite/CRA filter VITE_/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.

What is environment variable 12-factor config vs hard-coded

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
.env anatomy KEY VALUE quoting multiline comments
  • No export: shell uses export PORT=3000, .env uses bare PORT=3000export is ignored by many parsers and breaks Windows.
  • Quotes: wrap if value has spaces, #, or \n. GREETING="Hello world" needs quotes; PORT=3000 doesn't. Double quotes allow \n → newline for private keys; single quotes may be literal.
  • Comments: full-line # comment only; KEY=val # comment may treat # comment as 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 export and no spaces around =: PORT = 3000 with spaces may be parsed as PORT with value 3000 with 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)

Loading .env Node Python Docker system precedence

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

Security .gitignore never commit env leak rotate prod vault

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
Advanced expansion defaults multiline precedence .env ladder

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
Validate env envalid convert JSON env generate

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#123 without quotes → parser treats #123 as comment → value sk. Fix: API_KEY="sk#123".
  • Spaces around =: PORT = 3000 with spaces → some parsers include leading space in value " 3000"parseInt(" 3000")=3000 passes but URL " https://..." fails. Avoid spaces.
  • Windows line endings: \r\n leaves \r in value on Linux — configure .gitattributes text eol=lf.
  • Export prefix in .env: export PORT=3000 breaks 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.

Production pattern — No .env file on server:
# 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_URL exposed, API_KEY stays 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

MethodBest forRotationAudit
.env file (gitignored)Local dev/test convenienceManual edit + restartNone
dotenv + .env.exampleOnboarding new devsTemplate onlyNone
AWS Secrets Manager / Doppler / VaultProd, team shared, rotation, auditAuto rotation + revokeCloudTrail, versioned
K8s Secret / ConfigMapK8s prodRecreate + rolloutetcd + 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.

Onboarding checklist — New dev in 5 min:
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

  1. File location: dotenv.config() looks in cwd (where you run node), not file location. Run from project root or pass path: __dirname + '/.env'.
  2. Cached after import: process.env is read at import time — changing .env requires restart (nodemon helps).
  3. Name typo: DATBASE_URL vs DATABASE_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).

Production pattern — No .env file on server, revisited:

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=3000process.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-dotenvfrom 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.