Environment variables are how software learns where it is running: which database to connect to, which API keys to use, which log level to apply. They are the mechanism behind the Twelve-Factor App methodology's "config" factor, and they are the default secret-handling mechanism in every major cloud platform. Yet they are also the source of a surprising number of production incidents — missing values, wrong types, and secrets accidentally committed to repositories.
What changed in 2026
- Secrets managers replaced
.env files at most companies. Doppler, AWS Secrets Manager, 1Password Secrets Automation, and HashiCorp Vault are the standard tools — they inject env vars at runtime without storing values in files.
- Vite and Next.js 15 enforce
NEXT_PUBLIC_ / VITE_ prefix rules more strictly, preventing accidental exposure of server secrets to the browser bundle.
- Zod v4 and pydantic v2 made schema-based env var parsing the idiomatic approach in TypeScript and Python respectively.
- GitHub and GitLab secret scanning now block commits containing common secret patterns — but this is a safety net, not a strategy.
How environment variables work
# Shell — set for one command
DATABASE_URL=postgres://localhost/mydb node server.js
# Shell — export to all child processes
export LOG_LEVEL=debug
# .env file (dev only — never commit real secrets)
DATABASE_URL=postgres://localhost/devdb
LOG_LEVEL=debug
PORT=3000
A process reads its environment at startup via process.env (Node.js), os.environ (Python), or std::env (Rust). Values are always strings — your code must parse them.
Validate at startup — always
The single best practice: parse and validate all env vars when the process starts. Fail with a descriptive error before accepting traffic.
// TypeScript — Zod v4
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
export const env = EnvSchema.parse(process.env);
// Type-safe: env.PORT is number, not string
# Python — pydantic v2
from pydantic_settings import BaseSettings
from pydantic import AnyUrl
class Settings(BaseSettings):
database_url: AnyUrl
port: int = 3000
log_level: str = "info"
stripe_secret_key: str
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings() # raises ValidationError at startup if invalid
Secrets management comparison
| Tool |
Best for |
How it works |
.env file |
Local dev only |
File on disk — never commit real secrets |
| Doppler |
Teams needing a UI and CLI |
Injects vars at process start; syncs to CI |
| AWS Secrets Manager |
AWS-native workloads |
Fetch at startup or mount via ECS/Lambda |
| 1Password Secrets |
Teams already using 1Password |
op run -- injects vars from 1Password vault |
| HashiCorp Vault |
Self-hosted, complex rotation needs |
Agent or SDK fetch |
The correct production pattern: secrets live in the manager, the application receives them as env vars injected at deploy time. No secrets files anywhere in the repo.
What belongs in env vars
| Use env vars for |
Use something else for |
| Database URLs and credentials |
Large binary config files |
| API keys and tokens |
Multi-line TLS certificates (mount as file) |
| Feature flags (simple boolean) |
Complex nested configuration (use a config file) |
| Port numbers and hostnames |
Per-user preferences |
How to pick
- Local dev — use a
.env file loaded by dotenv; commit a .env.example with dummy values.
- CI/CD — use the platform's built-in secrets (GitHub Actions
secrets.*, GitLab CI variables).
- Production services — use a secrets manager; never bake secrets into Docker images.
- Type safety — always parse with Zod or pydantic; never use
process.env.VALUE as MyType.
Common mistakes
Committing .env to git. Add .env to .gitignore on day one. Commit .env.example with placeholder values instead.
Not having a .env.example. New team members discover missing vars at runtime. A documented example file prevents this.
Silently using undefined values. process.env.FOO ?? "default" without validation means a typo in the var name silently uses the default in production.
Storing structured data. CONFIG={"a":1,"b":2} is fragile. Use proper config files for nested structures.
What to skip
- Putting TLS private keys in env vars — they are multi-line, easy to corrupt, and better handled as mounted files or secrets manager references.
- Hardcoding fallbacks that could be used in production —
process.env.API_KEY ?? "test-key" is a prod incident waiting to happen.
- Reading env vars scattered throughout the codebase — centralise all env access in one validated config module.
FAQ
What is the difference between env vars and secrets?
All secrets are env vars (at runtime), but not all env vars are secrets. PORT=3000 is configuration; STRIPE_SECRET_KEY=sk_live_... is a secret. Secrets require rotation, audit trails, and restricted access.
How do I pass env vars to a Docker container?
Use --env-file, -e, or docker compose environment: / env_file: keys. Never bake secrets into the image layer with ENV in a Dockerfile.
Can env vars be changed without restarting the process?
No. Env vars are read at startup; the process sees a snapshot. For dynamic config, use a config service or feature flag tool.
How do I handle different values per environment?
Separate .env.development, .env.staging, .env.production files for reference (with fake values), and inject real values via a secrets manager per environment.
Where to go next
See How to handle secrets in 2026, Error handling explained in 2026, and Logging explained in 2026.