Environment Variables: How to Manage Secrets Without Leaking Them
How environment variables work, the right way to use .env files across environments, and the concrete practices that prevent secrets from ending up in git history or logs.
Why environment variables exist and what problem they solve
An environment variable is a named value accessible to a process, set by the operating system or the shell that launches the process. The key property is that it is external to the application's source code — the code reads a value from the environment at runtime, rather than having that value baked into the source.
The problem environment variables solve is configuration that differs between environments (development, staging, production) or that contains sensitive data (API keys, database passwords, tokens) that must never be committed to version control. When configuration is in source code, changing it requires a code change and redeploy. When it is in environment variables, it can be changed per-environment without touching the codebase.
How environment variables work
Every process runs in an environment — a dictionary of name-value string pairs inherited from its parent process. When you open a terminal, your shell has an environment. When the shell launches a program, that program inherits a copy of the shell's environment. The program can read values from this environment but cannot modify the parent's environment.
# Set for the duration of the current shell session
export DATABASE_URL="postgres://user:pass@localhost/mydb"
# Set for a single command only (does not persist in the shell)
DATABASE_URL="postgres://user:pass@localhost/mydb" node server.js
# Read in different languages
# Node.js
const dbUrl = process.env.DATABASE_URL;
# Python
import os
db_url = os.environ.get('DATABASE_URL') # returns None if not set
db_url = os.environ['DATABASE_URL'] # raises KeyError if not set
# Go
import "os"
dbUrl := os.Getenv("DATABASE_URL") // returns "" if not set
# Shell
echo $DATABASE_URLThe .env file pattern: convenience vs security
Setting environment variables manually for every shell session is impractical for development. The .env file pattern solves this by storing variable definitions in a file that a library (like dotenv in Node.js or Python) reads at application startup and injects into the process environment.
# .env file (NEVER commit this to git)
DATABASE_URL=postgres://user:devpassword@localhost/myapp_dev
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_abc123
JWT_SECRET=dev-secret-not-for-production
# Node.js — load with dotenv
import 'dotenv/config'; // reads .env and sets process.env
const key = process.env.STRIPE_SECRET_KEY;
# Python — load with python-dotenv
from dotenv import load_dotenv
load_dotenv()
key = os.environ.get('STRIPE_SECRET_KEY')The critical rule: .env files that contain real secrets must never be committed to git. Add .env to your .gitignore on the first day of the project, before any secrets are added. A file committed once — even briefly, even if later deleted — remains in git history and can be recovered withgit log -p.
The .env file convention: what each file is for
Most projects use multiple .env files for different purposes. The convention most frameworks follow (Next.js, Vite, Create React App, Laravel) is:
.env # Default — checked into git, no secrets, safe defaults .env.local # Local overrides — NOT in git, actual dev secrets .env.development # Development environment defaults — can be in git .env.production # Production defaults (no secrets) — can be in git .env.development.local # Local dev overrides — NOT in git .env.production.local # Local production overrides — NOT in git # .gitignore must include: .env.local .env.*.local .env.production.local
The files that are safe to commit are those containing only non-secret defaults — feature flags, API base URLs that are public, timeout values, log levels. Never put database passwords, API keys, or signing secrets in committed files, even if the repository is private. Private repositories get cloned, forked, and sometimes accidentally made public.
The .env.example pattern
The standard way to document what environment variables a project needs without committing actual secrets is a .env.example file committed to git with placeholder values. New team members copy it to .env.local and fill in the real values.
# .env.example (committed to git) DATABASE_URL=postgres://user:password@localhost/myapp_dev REDIS_URL=redis://localhost:6379 STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE JWT_SECRET=generate-with-openssl-rand-hex-32 SENDGRID_API_KEY=SG.YOUR_KEY_HERE APP_URL=http://localhost:3000
Keep .env.example up to date as a discipline. When you add a new variable, update the example file in the same commit. A .env.example that is out of date causes onboarding failures and wastes hours of developer time.
Production: never use .env files
In production, environment variables should be set through your hosting platform's secrets management, not through .env files on disk. Every major deployment platform provides a way to set environment variables securely: AWS Secrets Manager, Google Secret Manager, Heroku config vars, Railway variables, Vercel environment variables, Kubernetes Secrets, Docker secrets, GitHub Actions secrets. These systems:
Encrypt secrets at rest. Restrict access via IAM/RBAC. Provide audit logs of who accessed what and when. Support rotation without application redeployment. Prevent secrets from appearing in Dockerfiles, deployment scripts, or build artifacts.
A .env file on a production server is a single point of failure — if that server is compromised, all secrets are in one readable file. Distributed secrets management with per-secret access controls is significantly harder to exploit.
Validating environment variables at startup
Applications that crash or behave unexpectedly because a required environment variable is missing are harder to debug than applications that fail fast with a clear error on startup. Validate required variables when the application starts, before serving any requests.
// Node.js — validate at startup with a schema (Zod)
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production'])
});
const env = envSchema.parse(process.env);
// Throws ZodError at startup if any required var is missing or invalid
// Safe to use env.DATABASE_URL throughout the app — type is stringThis pattern — validate the full environment at startup with a schema — gives you two guarantees. First, missing required variables cause an immediate crash with a clear error message naming the specific variable, rather than a runtime crash minutes later when the code path that reads the variable is first hit. Second, the parsed env object has TypeScript types inferred from the schema, so misuses are caught at compile time.
What to never do with environment variables
Log them. Application logs often end up in centralized logging systems, S3 buckets, or third-party services. Never log process.env or any object that contains secrets. If you need to log that a config was loaded, log only which variables were set (names only), not their values.
Pass them in URLs. URLs appear in access logs, browser history, and Referer headers. Never put secrets in URL query parameters, even for internal services.
Expose them client-side unintentionally. In frameworks like Next.js, variables prefixed with NEXT_PUBLIC_ are bundled into the client-side JavaScript and visible to anyone who inspects the browser bundle. Only prefix variables with the public marker if they are genuinely safe for the public to see.
Use weak, guessable values in development. A development secret that is also used in production — or that an attacker can predict — collapses the security boundary. Generate real secrets with a cryptographically secure source even for development. A 32-byte hex secret generated with openssl rand -hex 32 takes two seconds to create and eliminates an entire class of risk.