env.dev

Environment Variable Best Practices (2026)

How to manage env vars without leaking secrets: SCREAMING_SNAKE_CASE naming, .env files in .gitignore from day one, validation at startup, typed config objects, and rotation.

Last updated:

Environment variables are the universal configuration interface — every language reads them, every cloud platform injects them, every container runtime supports them. They are also one of the largest single sources of production secret leaks. Toyota's 2017–2022 customer-data exposure traced back to an access key committed to a public repo. Uber's 2022 internal breach started with stolen credentials. The mitigation is unglamorous and well-documented: SCREAMING_SNAKE_CASE naming, .env files in .gitignore from commit zero, validation at startup, a typed configuration object, and .env never used in production. The 12-factor methodology (12factor.net, 2011) codified the rules and they have not aged. This guide walks through them with concrete commands, real tools, and a list of what to grep for in your codebase right now.

What's the universal naming convention?

Use SCREAMING_SNAKE_CASE — uppercase letters, underscores between words, no dashes. Every operating system, language, and orchestrator recognizes the convention. Then prefix by domain to avoid collisions and aid discoverability: DATABASE_URL not just URL, SMTP_SERVER_HOST not HOST.

Three rules for the naming itself:

  • Adopt vendor prefixes when integrating: AWS_* for AWS, AZURE_* for Azure, GOOGLE_APPLICATION_CREDENTIALS for GCP, STRIPE_* for Stripe. The tooling expects them.
  • Avoid clever abbreviations: SMTP_SERVER_HOST beats SMTP_SRV_H. Production debugging at 2 AM is not the time to play guess-the-vowel.
  • Include the unit: REQUEST_TIMEOUT_MS, not TIMEOUT. This rule alone prevents an entire category of bugs where someone passes seconds to a function expecting milliseconds.

Why is "never commit secrets" rule #1?

A secret committed to git is a secret that lives forever. Even if you delete the file in the next commit, the value remains in the repository history and can be retrieved by anyone with read access — including anyone who cloned the repo before the deletion, anyone with a fork, and any automated scraper. The 2022 Toyota incident exposed ~300,000 customer records due to an AWS access key in a public commit; rotation came weeks later.

The defenses, in order of how much pain they save:

  1. .gitignore from day one: .env, .env.local, .env.*.local, *.pem, *.key. Add these before the first commit, not after.
  2. Pre-commit secret scanners: gitleaks (Apache 2.0), detect-secrets (Yelp, Apache 2.0), git-secrets (AWS Labs). Configure as a mandatory hook so no developer can commit a key by accident.
  3. Server-side scanning: GitHub, GitLab, and Bitbucket all ship secret scanning that flags committed credentials and sometimes auto-rotates with vendor partners. Enable it.
yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

If a secret has already been committed: rotate immediately, then clean the history. Rotation is the only fix that matters — assume the value is already compromised. git filter-repo or BFG can scrub history but cannot un-leak.

When are .env files OK and when are they not?

.env files are excellent for local development — they let developers configure their environment without modifying system-level settings. They are wrong for production. A plain-text file on a deployment host has no encryption at rest, no access control, no audit trail, and no rotation. Production secrets belong in a managed system: AWS Secrets Manager, AWS Parameter Store, Azure Key Vault, GCP Secret Manager, HashiCorp Vault, or Kubernetes Secrets sealed via SOPS or the External Secrets Operator.

Commit a .env.example file at the repo root with placeholder values — it serves as documentation, a template for new developers, and a checklist of "what variables this app needs":

bash
# .env.example — copy to .env and fill in real values
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
LOG_LEVEL=debug
APP_ENV=development

For everything that goes in a .env file — quoting, multi-line values, comments, escapes — see the .env file syntax guide.

Why validate at startup?

The fail-fast pattern: check every required variable before the app accepts traffic. A missing DATABASE_URL should produce a clear startup error pointing at the missing key, not a cryptic Cannot read property 'connect' of undefined ten minutes into the first request. Most language ecosystems ship validators with type coercion built in:

  • Node.js: envalid, @t3-oss/env-core (with Zod), znv.
  • Python: pydantic-settings, environs.
  • Go: envconfig (Kelsey Hightower), caarlos0/env.
  • Rust: envy (serde deserialize), config (multi-source).
  • .NET: IConfiguration + IOptions<T> with [Required] data annotations.
typescript
// envalid example — fails the build at startup with a clear error
import { cleanEnv, str, port, bool, url } from 'envalid';

export const env = cleanEnv(process.env, {
  DATABASE_URL: url(),
  PORT:         port({ default: 3000 }),
  LOG_LEVEL:    str({ choices: ['debug', 'info', 'warn', 'error'], default: 'info' }),
  DEBUG:        bool({ default: false }),
});

// env.DATABASE_URL is a guaranteed-present string
// env.PORT       is a number
// env.LOG_LEVEL  is the literal union 'debug' | 'info' | 'warn' | 'error'

Or check with our .env validator before deploying — it lints syntax, catches deprecated variables, and flags missing production-required keys.

How do you build a typed, centralized config object?

The single most important pattern: one file in your codebase reads process.env / os.environ / env::var; everything else imports the resulting config object. This gives you (a) one inventory of required variables, (b) easy mocking in tests, and (c) easy refactoring when you switch from .env to a secrets manager.

typescript
// config.ts — the only file that reads process.env
import { cleanEnv, str, port, bool, num } from 'envalid';

export const config = cleanEnv(process.env, {
  // Database
  DATABASE_URL:        str(),
  DATABASE_POOL_SIZE:  num({ default: 10 }),
  DATABASE_SSL_MODE:   str({ default: 'prefer' }),

  // Server
  PORT:                port({ default: 3000 }),
  HOST:                str({ default: '0.0.0.0' }),

  // Features
  FEATURE_NEW_DASHBOARD: bool({ default: false }),
});

// Everywhere else
import { config } from './config.ts';
const port = config.PORT;

Secrets rotation without downtime

Secrets have a shelf life. Rotation policies, employee turnover, leaks, and compliance frameworks all require the ability to swap a credential without downtime. Two patterns make this manageable:

  • Dual-credential rotation: support two valid passwords or keys at once. AWS RDS, Azure Database, and Stripe all expose a "primary + secondary" key model. Old remains valid while the new key propagates; flip the active label, then revoke the old.
  • Managed rotation: AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager all schedule rotation on your behalf, often with first-class integration to the downstream service (e.g. RDS rotation Lambdas).

The application's job: read the current value at startup or via a refreshable cache. Don't hardcode rotation periods — they belong in infra config, not application code.

Don't branch on NODE_ENV; configure per environment

A common antipattern: if (NODE_ENV === 'staging') { /* connect to staging DB */ }. This couples your code to the environment name and makes it hostile to add a new stage (QA, demo, performance testing). The 12-factor approach: identify the environment for logging and feature gating, but let each environment provide the correct values for every config variable. Staging connects to a staging database not because the code branches, but because DATABASE_URL resolves to a staging host in that environment.

Principle of least privilege

Service accounts and API keys should have the minimum permissions required to do their job. A queue worker should not have admin on your cloud account. A read-only web tier should use a read-only database connection string. Separate credentials for separate concerns: deploy keys are not application keys; CI keys are not production keys; test keys are not real keys.

Audit periodically: which env vars contain credentials? Which of those credentials are still in use? Which could be downgraded to read-only? Treat every secret-bearing variable as an attack surface — fewer keys with smaller scopes equals smaller blast radius when one leaks.

Document everything required

Every project should ship a list of required and optional environment variables. Variable name, purpose, required-ness, default, example value. README.md or a dedicated CONFIGURATION.md works; keep it in the same commit as the config module so the two cannot drift:

markdown
| Variable          | Required | Default | Description                       |
|-------------------|----------|---------|-----------------------------------|
| DATABASE_URL      | Yes      | —       | PostgreSQL connection string      |
| PORT              | No       | 3000    | HTTP server port                  |
| LOG_LEVEL         | No       | info    | debug / info / warn / error       |
| REDIS_URL         | Yes      | —       | Redis connection string           |
| FEATURE_NEW_UI    | No       | false   | Enable new UI feature flag        |

Practical checklist

Was this helpful?

Read next

Env Variables Security: Secrets, Leaks & Best Practices

Why environment variables are not truly secure and what to do about it: secret rotation, leak detection, client-side risk, and secrets managers.

Continue →

Frequently Asked Questions

What is the universal naming convention for environment variables?

SCREAMING_SNAKE_CASE — uppercase letters with underscores between words. Prefix by domain (DATABASE_URL not URL, SMTP_SERVER_HOST not HOST). Adopt vendor prefixes (AWS_*, AZURE_*, STRIPE_*). Always include the unit in the name (REQUEST_TIMEOUT_MS, not TIMEOUT).

Are .env files safe in production?

No. .env files are excellent for local dev but wrong for production — no encryption at rest, no access control, no audit trail, no rotation. Production secrets belong in a managed system: AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, HashiCorp Vault, or Kubernetes Secrets layered with SOPS / External Secrets Operator.

What do I do if I committed a secret to git?

Rotate immediately. Do not rely on rewriting git history alone — assume the secret is already compromised and scraped. Tools like git filter-repo or BFG can scrub history but cannot un-leak. The 2022 Toyota incident exposed ~300k records due to an AWS key in a public commit; rotation came weeks later.

Why validate environment variables at startup?

Fail-fast: a missing DATABASE_URL should produce a clear startup error pointing at the missing key, not a cryptic runtime null reference ten minutes into the first request. Tools: envalid (Node), pydantic-settings (Python), envconfig (Go), envy (Rust), IConfiguration + IOptions<T> (.NET).

Stay up to date

Get notified about new guides, tools, and cheatsheets.