Environment Variables Done Right
Environment variables seem simple, but teams mess them up constantly. Secrets end up in version control. Different environments behave differently because someone forgot to set a variable. Debugging becomes a nightmare because nobody knows which config is actually being used.
Let's fix that.
Why Environment Variables?
The core idea: separate configuration from code. Your code shouldn't know whether it's running in development, staging, or production. It should read configuration from the environment and behave accordingly.
This gives you:
- The same code running in all environments
- Secrets that never touch version control
- Easy configuration changes without code deploys
- Clear separation between what the code does and how it's configured
What Belongs in Environment Variables
Not everything should be an environment variable. Good candidates:
- Secrets: API keys, database passwords, encryption keys
- Environment-specific URLs: API endpoints, database hosts
- Feature flags that vary by environment
- Resource limits: timeouts, pool sizes, rate limits
Things that shouldn't be environment variables: business logic, static configuration that never changes, things that need type safety or validation.
The .env File Pattern
For local development, use a .env file that libraries like dotenv load automatically. This keeps your local config separate from code.
Critical rules:
- Never commit .env to git. Add it to .gitignore immediately.
- Do commit a
.env.exampletemplate with dummy values showing what variables are needed. - Keep .env files out of Docker images. Pass variables at runtime instead.
When someone clones your repo, they should copy .env.example to .env and fill in their own values. The example file documents what's needed without exposing real secrets.
Naming Conventions
Consistent naming prevents confusion. Use uppercase with underscores. Prefix related variables.
Good:
DATABASE_URLDATABASE_POOL_SIZESTRIPE_API_KEYSTRIPE_WEBHOOK_SECRET
Bad:
dbapiKeystripe-key
Grouping related variables with prefixes makes it obvious what they configure and helps when you have dozens of variables.
Validation at Startup
Don't wait until you need a variable to discover it's missing. Validate all required environment variables when your application starts.
Fail fast with a clear error message: "Missing required environment variable: DATABASE_URL"
In TypeScript, libraries like zod let you define and validate your config schema:
const env = envSchema.parse(process.env)
Now you have typed configuration and immediate feedback if something's missing or malformed.
Secrets Management in Production
For production, .env files aren't enough. You need proper secrets management:
Cloud provider solutions: AWS Secrets Manager, Google Cloud Secret Manager, Azure Key Vault. They integrate with their deployment platforms and handle rotation.
Platform features: Vercel, Heroku, Railway all have built-in environment variable management with encryption and access controls.
Self-hosted options: HashiCorp Vault if you need something you control.
The key features you want: encrypted storage, access controls, audit logs, and ideally automatic rotation for things like database passwords.
Different Environments, Different Values
You typically have at least three environments: development, staging, and production. Each needs different configuration.
Development might use a local database. Staging uses a test database with fake data. Production uses the real thing with real data.
Keep environment-specific configs clearly separated. Some teams use different .env files (.env.development, .env.staging). Others manage everything in their deployment platform and only use .env locally.
Don't Leak Secrets to the Frontend
This is a common and dangerous mistake. Environment variables in frontend code get bundled into JavaScript that anyone can read.
Next.js requires prefixing public variables with NEXT_PUBLIC_ and refuses to expose others. Other frameworks have similar patterns. Follow them.
If a secret ends up in your frontend bundle, assume it's compromised. Rotate it immediately.
Documenting Your Variables
As your app grows, you'll have dozens of environment variables. Document them.
Your .env.example should include comments explaining what each variable does and what valid values look like. Consider a separate documentation page for complex configurations.
When someone new joins the team, they shouldn't have to reverse-engineer your code to understand what configuration is needed.
Handling Defaults
Some variables should have sensible defaults. Others should fail if not provided.
A log level might default to "info" in production. An API key should never have a default, since you want to fail explicitly if it's missing rather than silently using a placeholder.
Be intentional about defaults. When you provide a default, you're saying "it's okay if this isn't configured." Make sure that's actually true.
Local Development Tips
Use a tool like direnv to automatically load .env when you enter a project directory. You don't have to remember to source anything.
For Docker Compose, reference your .env file in docker-compose.yml. Variables flow through automatically.
When debugging, print your loaded configuration at startup, minus the actual secret values. Knowing that DATABASE_URL is set and STRIPE_API_KEY is missing saves debugging time.
Quick Security Checklist
Before shipping, verify:
- .env is in .gitignore
- No secrets in code or config files committed to git
- Production secrets are in a proper secrets manager
- Frontend code doesn't contain backend secrets
- Required variables are validated at startup
Environment variables are simple in concept but easy to mess up in practice. Get them right, and you'll avoid a whole category of bugs and security issues.