Environment Variables in Node.js with dotenv
Environment variables in Node.js: dotenv setup, .env files, validation with Zod, production secrets management, and avoiding the common mistake of committing secrets to git..
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
Environment variables (process.env) are the standard way to configure Node.js applications across environments. The dotenv package loads variables from a .env file into process.env at application star
Think of environment variables like the settings on a restaurant's kitchen display screen. The chef doesn't hardcode the oven temperature or the specials menu into the recipe book; instead, they read them from a screen that can be changed without rewriting the recipes. Similarly, environment variables let your app read configuration (like database passwords or API keys) from the system, so you can change settings without touching the code.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
DATABASE_URL=postgres://localhost:5432/myapp — the line that, when accidentally committed to a public repo, costs companies millions in breached data. Environment variables are the most basic infrastructure decision in any Node.js application, yet teams consistently get them wrong: committing .env files, lacking validation, using the same config for development and production. This article covers the correct setup, validation patterns, production secrets management, and the one pattern that prevents the most common environment variable disaster.
Why Environment Variables Matter in Production
Hardcoding configuration values like database passwords, API keys, or secret tokens directly into your source code is a security risk and a maintenance nightmare. In production, you need to separate configuration from code to allow different settings across environments without modifying the codebase. Environment variables provide a standard way to inject configuration into your Node.js application at runtime. They keep secrets out of version control, enable per-environment overrides, and simplify deployment pipelines. The Twelve-Factor App methodology explicitly recommends storing config in environment variables. Without them, you risk exposing credentials in Git history, breaking deployments due to config mismatches, or accidentally shipping debug settings to production. This article walks you through using the dotenv package to manage environment variables safely and effectively.
Setting Up dotenv in Your Project
The dotenv package loads environment variables from a .env file into process.env. Install it as a production dependency: npm install dotenv. Then, at the very top of your application's entry point (e.g., app.js or index.js), add require('dotenv').config(). This reads the .env file and merges its key-value pairs into process.env. If you're using ES modules, use import 'dotenv/config' instead. The .env file should never be committed to version control; instead, provide a .env.example file with dummy values. For local development, each developer creates their own .env file. In production, you typically set environment variables through your hosting platform (e.g., Heroku config vars, AWS ECS environment, Docker compose) and skip the .env file entirely. This setup ensures your code works identically across all environments.
dotenv.config() as early as possible, before any other module that reads environment variables. Otherwise, those modules might see undefined values.The .env File Format and Best Practices
The .env file uses a simple KEY=VALUE format. Each line defines one variable. Values can be quoted (single or double) if they contain spaces or special characters. Comments start with #. There's no need to export variables; dotenv handles that. Best practices: use uppercase with underscores for variable names (e.g., DB_HOST). Group related variables with comments. Never store sensitive values in .env.example. Use default values in code when a variable might be missing (e.g., const port = process.env.PORT || 3000). For boolean values, use strings like 'true' and 'false' and parse them explicitly. Avoid using .env in production; instead, set variables via the OS or container orchestration. If you must use a file in production, ensure it's outside the application root and has restricted permissions (e.g., chmod 600).
DB_URL=${DB_HOST}:${DB_PORT}. Use a library like dotenv-expand if you need that.Accessing Environment Variables in Code
After loading dotenv, you access variables via process.env.VARIABLE_NAME. This returns a string (or undefined if not set). Always validate required variables at startup. For example, if your app needs a database URL, check if it exists and crash early with a clear error message. Use defaults for optional variables. For numbers, parse them explicitly (e.g., parseInt(process.env.PORT, 10)). For booleans, compare the string value (e.g., process.env.DEBUG === 'true'). Never trust that a variable is set; always handle missing values gracefully. In production, you might want to use a validation library like joi or env-var to enforce types and required fields. This prevents runtime errors due to misconfiguration.
parseInt with radix 10 avoids octal interpretation.process.env.PORT was set to '3000' but the code used +process.env.PORT which worked, until someone set PORT to 'abc' by accident. Always validate and parse with fallbacks.Managing Multiple Environments (Development, Staging, Production)
Different environments need different configurations. The simplest approach is to have separate .env files per environment, e.g., .env.development, .env.staging, .env.production. Then load the appropriate one based on NODE_ENV. You can use dotenv.config({ path: .env.${process.env.NODE_ENV} }). However, in production, you should not use .env files at all; set variables via the platform. For local development, you can have a .env file that overrides defaults. Another pattern is to use a single .env file with all variables, but override specific ones with environment-specific files. The key is consistency: your code should read from process.env regardless of how the variables were set. Use a configuration module that centralizes all environment variable access, making it easy to see what's expected.
dotenv.config() is called only when NODE_ENV is not 'production', or we skip .env loading entirely in production.Security: Keeping Secrets Out of Source Control
The primary reason to use environment variables is security. Never commit secrets to Git. Add .env to .gitignore immediately. Use .env.example as a template with placeholder values. For teams, consider using a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Doppler. These tools inject secrets at runtime, avoiding files altogether. If you must use files, encrypt them (e.g., with git-crypt or sops). Also, be careful with logging: never log environment variables directly. A common production incident is accidentally printing process.env in a debug log, exposing secrets in log aggregation tools. Use a sanitized logger that redacts sensitive fields. Finally, rotate secrets regularly and audit who has access.
Testing with Environment Variables
Testing code that relies on environment variables can be tricky because process.env is global. You have a few options: (1) Set variables in your test runner's setup (e.g., Jest's setupFiles). (2) Use a library like dotenv with a test-specific .env.test file. (3) Mock process.env using a utility like jest.resetModules() to reload modules with different env values. The cleanest approach is to inject configuration via a config module that can be mocked. For example, instead of reading process.env.DB_HOST directly in your database module, read it from a config object that you can replace in tests. This makes your code testable without side effects. Always reset environment variables between tests to avoid leakage.
Common Pitfalls and How to Avoid Them
Several mistakes plague developers new to dotenv. (1) Forgetting to call before accessing variables. (2) Committing the dotenv.config().env file. (3) Using .env in production. (4) Assuming variables are always set. (5) Not parsing types (e.g., treating 'false' as truthy). (6) Overwriting existing environment variables (dotenv does not overwrite by default, but you can force it with { override: true }). (7) Loading dotenv in multiple places, causing confusion. (8) Using spaces around = in .env file (they become part of the key or value). (9) Expecting dotenv to expand variables. (10) Not handling missing .env file gracefully (dotenv doesn't throw if the file is missing, but you might want to warn). Avoid these by following the practices outlined in this article: load early, validate, type-check, and never commit secrets.
KEY = VALUE will set the key to ' VALUE' (with leading space). Always use KEY=VALUE without spaces.process.env.FEATURE_FLAG was 'false' but the condition if (process.env.FEATURE_FLAG) still executed. They forgot that any non-empty string is truthy. Always compare to the string 'true'.Alternatives to dotenv and When to Use Them
While dotenv is great for local development, production environments often need more robust solutions. (1) Platform-native variables: Heroku, AWS ECS, Docker Compose, and Kubernetes all support setting environment variables natively. (2) Secrets managers: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager provide encrypted storage and rotation. (3) Configuration services: Consul, etcd, or Spring Cloud Config for distributed systems. (4) Encrypted files: Use sops or git-crypt to encrypt .env files in the repo. (5) Environment variable validators: Libraries like env-var or joi enforce types and required fields. For most Node.js apps, start with dotenv for development and switch to platform variables for production. If you need to manage secrets across many services, invest in a secrets manager. The principle remains: separate config from code.
Putting It All Together: A Production-Ready Setup
Here's a complete, production-ready pattern. Your entry point loads dotenv only if not in production. A config module validates and exports typed variables. The rest of the app imports config, not process.env directly. This centralizes configuration, makes testing easy, and prevents scattered process.env calls. In production, you set variables via the platform (e.g., Docker environment, Kubernetes secrets). The .env file is never committed. Use .env.example as documentation. Add a startup check that crashes the app if required variables are missing. This pattern has served us well across dozens of Node.js services, from small APIs to microservices handling millions of requests.
Node 20+ Built-in --env-file CLI Flag
Starting with Node 20, you can load environment variables from a file without any third-party library. The --env-file flag allows you to specify a path to a .env file when running your application. This is a zero-dependency solution for simple use cases. For example, node --env-file=.env server.js loads variables from .env into process.env. This approach is ideal for small projects or scripts where you want to avoid adding dotenv. However, it lacks features like variable expansion, override, or multi-file support. For production, you may still prefer dotenv for its flexibility and ecosystem. Note that --env-file is still experimental in Node 20, so use with caution in critical environments.
process.loadEnvFile() and util.parseEnv()
Node 20 also introduces process.loadEnvFile() as a programmatic alternative to the CLI flag. This method loads a .env file into process.env at runtime. For example, process.loadEnvFile('.env') does the same as . Additionally, dotenv.config()util.parseEnv() parses a string of key-value pairs into an object, useful for custom parsing. These built-ins reduce dependency on dotenv for basic loading. However, they don't support variable expansion, override, or multi-file loading. If your project needs those features, stick with dotenv. The built-ins are best for minimal setups or when you want to avoid third-party code in a security-critical context.
dotenv-expand for Variable Expansion
Variable expansion allows you to reference other environment variables within your .env file using ${VAR} syntax. For example, DATABASE_URL=${HOST}:${PORT}/db. The dotenv-expand package adds this capability to dotenv. Install it with npm install dotenv-expand, then use dotenvExpand.expand(. This is essential for reducing duplication and centralizing configuration. Without expansion, you'd have to hardcode values or use string concatenation in code. Note that expansion order matters: variables are expanded in the order they appear in the file. Use it wisely to avoid circular references. For production, always validate that expanded variables resolve correctly.dotenv.config())
DOTENV_CONFIG_ Env Config and Override Option
dotenv supports configuration via environment variables prefixed with DOTENV_CONFIG_. For example, DOTENV_CONFIG_PATH sets the path to the .env file, and DOTENV_CONFIG_DEBUG enables debug logging. This is useful for CI/CD pipelines or Docker containers where you want to override the default behavior without code changes. Additionally, dotenv's override option (available since v16) allows you to overwrite existing process.env values. By default, dotenv does not override existing variables. Set { override: true } in to force overwrite. This is critical when you need to ensure that environment variables from a file take precedence over system-set ones, or vice versa. Use with caution to avoid accidental overwrites.dotenv.config()
Multi-File Loading Patterns
In complex projects, you may need to load multiple .env files, such as .env for defaults, .env.local for local overrides, and .env.production for production-specific values. dotenv supports this by calling multiple times. The order matters: later calls override earlier ones. A common pattern is to load dotenv.config().env first, then .env.local (if it exists), and finally .env.${NODE_ENV}. This allows you to have a base configuration that can be overridden per environment. Use the path option to specify each file. For security, ensure that .env.local is in .gitignore. This pattern is widely used in frameworks like Next.js and is production-proven.
The Case of the Missing .env in Production
- Never assume dotenv will silently fail; always handle missing files explicitly.
- Use environment-specific configuration loading (e.g., if (process.env.NODE_ENV !== 'production')
dotenv.config()). - Implement a startup validation that checks for required environment variables and fails loudly.
| File | Command / Code | Purpose |
|---|---|---|
| .env.example | DB_HOST=localhost | Why Environment Variables Matter in Production |
| app.js | require('dotenv').config(); | Setting Up dotenv in Your Project |
| .env | DB_HOST=prod-db.example.com | The .env File Format and Best Practices |
| config.js | require('dotenv').config(); | Accessing Environment Variables in Code |
| config.js | const dotenv = require('dotenv'); | Managing Multiple Environments (Development, Staging, Produc |
| .gitignore | .env | Security |
| __tests__ | describe('config', () => { | Testing with Environment Variables |
| bad-example.js | const db = require('./db'); // reads process.env.DB_HOST before dotenv loaded | Common Pitfalls and How to Avoid Them |
| using-env-var.js | const env = require('env-var'); | Alternatives to dotenv and When to Use Them |
| index.js | if (process.env.NODE_ENV !== 'production') { | Putting It All Together |
| terminal | node --env-file=.env server.js | Node 20+ Built-in --env-file CLI Flag |
| app.js | process.loadEnvFile('.env'); | process.loadEnvFile() and util.parseEnv() |
| config.js | const myEnv = dotenv.config(); | dotenv-expand for Variable Expansion |
| config.js | dotenv.config({ override: true }); | DOTENV_CONFIG_ Env Config and Override Option |
| config.js | dotenv.config({ path: '.env' }); | Multi-File Loading Patterns |
Key takeaways
Interview Questions on This Topic
What is the purpose of the dotenv package?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Node.js. Mark it forged?
6 min read · try the examples if you haven't