Table of Contents
- Quick Answer: process.env
- When to Use Environment Variables (and When Not To)
- Understanding process.env
- Setting Environment Variables
- Loading .env Files: Native Support vs. dotenv
- Validating Environment Variables
- Security Best Practices
- Node.js-Specific Environment Variables
- Best Practices Summary
- Summary
- Frequently Asked Questions
We’ve all been there. You hardcode the base URL of the API your app talks to, everything works fine on your machine and in staging, and then the production deploy goes out still pointing at the staging endpoint. Now production is broken, and the only fix is to change the source code and redeploy. Or you crank the log level up to debug while chasing a bug, forget to change it back, and your production logs balloon overnight. These are exactly the problems that environment variables solve, and not just in JavaScript: they’re a standard mechanism that every language and operating system supports. In this article, we’ll see how to use environment variables in Node.js the right way.
The Twelve-Factor App methodology puts it simply: configuration that varies between deploys belongs in the environment, not in the code. The litmus test is straightforward: could your codebase be open-sourced right now without compromising any credentials? Mind you, getting credentials out of the code is only half the story: as we’ll discuss in a moment, environment variables aren’t the ideal home for secrets either. But the principle holds for everything that varies between deploys (API endpoints, log levels, feature flags, etc.).
Environment variables give you a clean separation between your code and its configuration, and that separation is what enables the modern deployment workflow: you build and package your application once, then promote the exact same artifact (say, a Docker image) from development to staging to production, with each environment supplying its own configuration at runtime. No rebuilds, no environment-specific bundles. On top of that, environment variables are language and OS agnostic, they keep environment-specific values out of version control, and every deployment platform you’ll encounter, whether it’s Docker, Kubernetes, AWS, or a CI/CD pipeline, supports them natively.
In this guide, you’ll learn everything you need to work with environment variables in Node.js effectively: when they’re the right tool (and when they’re not), the basics of process.env, the native --env-file flag introduced in Node.js 20.6, validation strategies, security best practices, and the full set of Node.js-specific variables you should know about.
Quick Answer: process.env
If you just need to read an environment variable, here’s the short version:
const apiUrl = process.env.API_URL
if (!apiUrl) { console.error('Missing API_URL environment variable') process.exit(1)}
console.log(`Using API at ${apiUrl}`)Run it by passing the variable inline:
API_URL=https://api.example.com node app.jsThat’s the core idea. But there are plenty of gotchas hiding behind this simplicity: string coercion traps, .env file loading options, validation strategies, and security concerns. Keep reading to learn how to handle environment variables like a pro.
When to Use Environment Variables (and When Not To)
Before we dive into the mechanics, it’s worth being honest about what environment variables are good at and where they fall short. Not every piece of configuration belongs in the environment.
Environment variables shine for configuration that changes between deploys and isn’t particularly sensitive:
- The port and host your server binds to
- Log levels and debug flags
- URLs of the services your app depends on
- Feature flags and tuning knobs
- The environment name itself (
NODE_ENV)
The common thread is that these values differ between your laptop, staging, and production, but nothing terrible happens if someone sees them. And because they live outside the code, you can build your application once and run that exact same artifact in every environment, just with a different configuration wrapped around it.
They’re a poor fit for other kinds of configuration, though:
- Large or structured configuration: if you need nested objects, lists, or dozens of related settings, a proper configuration file (JSON, YAML, TOML) loaded per environment is easier to read and review. Use environment variables to select the environment and override individual values, not to encode your entire config.
- Values that never change between deploys: those are constants, and they belong in code where they can be reviewed and versioned.
- Secrets, ideally: this one deserves its own explanation.
Here’s the nuance. The Twelve-Factor App methodology treats credentials as just another piece of config to store in the environment, and in practice that’s what most teams do. I’ll confess I’ve shipped more than one DATABASE_URL with a password embedded in it, simply because it’s so convenient. But convenience is exactly the problem: environment variables leak more easily than you might expect.
- Every child process inherits them by default (we’ll see this in detail later)
- They’re visible in
docker inspectoutput - On Linux, they can be read from
/proc/<pid>/environby any process running as the same user - Crash reporters and error-tracking tools love to capture the entire environment in their reports
For production systems, the more robust approach is a dedicated secrets manager, like HashiCorp Vault or AWS Secrets Manager, that your app queries at runtime, or secrets mounted as files with restricted permissions. Kubernetes, for example, explicitly recommends secret volumes over environment variables for exactly these reasons. Secrets managers also unlock something environment variables fundamentally can’t: integrated secret rotation. Because the app fetches secrets while it runs, credentials can be rotated centrally without restarting the process or redeploying the app. An environment variable, by contrast, is a snapshot taken at process startup: to pick up a new value, you have to restart.
That said, let’s stay pragmatic: secrets passed as environment variables, injected at runtime by a trusted platform and never written to a committed file, are still a huge improvement over credentials hardcoded in your source. If that’s where your project is today, you’re in good company. Just be aware of the trade-off, and treat “move the secrets to a proper secrets manager” as a natural next step as your application grows. We’ll come back to these risks in the security best practices section later in this guide.
With the “when” sorted, let’s get into the “how”, starting with how process.env actually works.
Understanding process.env
How process.env Works
process.env is a plain JavaScript object that Node.js populates at process startup from the operating system’s environment. It’s available globally without any imports, and it behaves like any other object in many ways: you can enumerate its keys, destructure it, and spread it.
// List all environment variablesconsole.log(Object.keys(process.env).length, 'variables found')
// Destructure specific variablesconst { HOME, USER, SHELL } = process.envconsole.log(`User: ${USER}, Home: ${HOME}, Shell: ${SHELL}`)
// Spread into a new objectconst envCopy = { ...process.env }What might surprise you is that process.env is not read-only. You can set and delete properties at runtime:
// Set a new variableprocess.env.MY_CUSTOM_VAR = 'hello'console.log(process.env.MY_CUSTOM_VAR) // 'hello'
// Delete a variabledelete process.env.MY_CUSTOM_VARconsole.log(process.env.MY_CUSTOM_VAR) // undefinedThese runtime changes are local to the current process. They don’t affect the operating system, they don’t persist after the process exits, and they don’t propagate back to parent processes. However, child processes spawned after a change will inherit the modified environment, because they receive a snapshot of the parent’s environment at spawn time.
The String Coercion Gotcha
This is one of the most common sources of bugs when working with environment variables. Because environment variables are strings at the operating system level, Node.js coerces everything to strings when you assign to process.env:
// Numbers become stringsprocess.env.PORT = 3000console.log(typeof process.env.PORT) // 'string'console.log(process.env.PORT) // '3000'
// Booleans become stringsprocess.env.DEBUG = trueconsole.log(process.env.DEBUG) // 'true'
// Even null and undefined become strings!process.env.EMPTY = nullconsole.log(process.env.EMPTY) // 'null'process.env.MISSING = undefinedconsole.log(process.env.MISSING) // 'undefined'This behavior means you should never use truthy/falsy checks for boolean environment variables. A value of "false", "0", or even "null" will all evaluate as truthy in a conditional check. Always parse and validate explicitly. See for yourself:
if ('0') { console.log('"0" is truthy') // this prints!}
if ('false') { console.log('"false" is truthy') // this prints too!}
if ('null') { console.log('"null" is truthy') // yep, this one as well!}
// The only falsy string is the empty stringif ('') { console.log('this never prints')}One more thing worth knowing: this implicit conversion is officially deprecated. Future versions of Node.js may throw an error when the assigned value is not a string, number, or boolean, so it’s a good habit to assign strings explicitly.
Platform Differences
On Unix-like systems (Linux, macOS), environment variable names are case-sensitive: PATH and Path are two different variables. On Windows, they are case-insensitive: PATH, Path, and path all refer to the same variable.
This matters if you’re building cross-platform applications. If you set process.env.myVar on Windows, you can read it as process.env.MYVAR and it will work. On Linux, it won’t. The safest approach is to use a consistent naming convention (typically UPPER_SNAKE_CASE) across all platforms.
Setting Environment Variables
Inline (Command-Line) Variables
The simplest way to pass an environment variable to a Node.js process is inline, right before the command:
PORT=3000 node app.jsYou can pass multiple variables at once:
LISTEN_ADDRESS=0.0.0.0 PORT=3000 NODE_ENV=production node app.jsThese variables exist only for the duration of that single command. Once the process exits, they’re gone. This approach is great for quick testing and one-off scripts.
Shell Export
To make a variable available for the entire shell session, use export in bash or zsh:
export LOG_LEVEL=debugnode app.js # Can read process.env.LOG_LEVELnode other.js # This one tooThe variable persists until you close the terminal or explicitly unset it. For variables you want available in every session, add the export line to your shell profile (~/.bashrc, ~/.zshrc, or similar).
System-Level Variables
You can also set environment variables at the system level through your OS settings (e.g., /etc/environment on Linux, System Preferences on macOS, System Properties on Windows). These are available to all processes for all users.
However, for application configuration, system-level variables are rarely the right choice. They’re hard to manage across environments, easy to forget about, and they affect every process on the machine. For application-specific configuration, .env files are the modern standard, and Node.js now supports them natively.
Loading .env Files: Native Support vs. dotenv
For years, the dotenv package was the standard way to load .env files in Node.js. Since Node.js 20.6, there’s a built-in alternative. Let’s explore both options and when to use each.
The Native --env-file Flag (Node.js 20.6+)
Starting with Node.js 20.6.0, you can load .env files directly using the --env-file CLI flag. No packages to install, no code to add:
node --env-file=.env app.jsCreate a .env file in your project root:
# Database configurationDATABASE_URL=postgres://localhost:5432/myapp
# Server settingsPORT=3000HOST=0.0.0.0
# Local dev secrets can live here, but ONLY if this file never gets committedAPI_KEY=your-api-key-here
# Values with special characters need quotesAPP_NAME="My Awesome App"GREETING='Hello, World!'QUERY_STRING="search=node&page=1"MESSAGE="This has a # hash inside"Since Node.js 20.7.0, you can specify multiple .env files. Later files take precedence over earlier ones, which is useful for layering base and local configurations:
node --env-file=.env --env-file=.env.local app.jsIn this example, any variables defined in .env.local will override those in .env. A common pattern is to commit .env with default (non-sensitive) values and add .env.local to .gitignore for developer-specific overrides.
For optional .env files that may not exist (e.g., .env.local in CI environments), use --env-file-if-exists (added in Node.js 22.9.0) to avoid errors when the file is missing:
node --env-file=.env --env-file-if-exists=.env.local app.jsAn important precedence rule: environment variables that are already set in the OS take precedence over values in the .env file. The .env file won’t overwrite existing variables. This is useful in production where you set real values through your deployment platform and the .env file only provides development defaults. But it can be surprising if you expect the file to always win.
process.loadEnvFile() and util.parseEnv()
Starting with Node.js 21.7.0 (backported to 20.12.0, so LTS users have it too), you can also load .env files programmatically:
// Load a .env file at runtimeprocess.loadEnvFile('.env')
// Or parse .env content from a stringimport { parseEnv } from 'node:util'
const envContent = `DATABASE_URL=postgres://localhost/mydbPORT=3000`
const parsed = parseEnv(envContent)console.log(parsed.DATABASE_URL) // 'postgres://localhost/mydb'console.log(parsed.PORT) // '3000'process.loadEnvFile() is handy for conditional loading (e.g., only in development) or for loading files from non-standard locations. util.parseEnv() is useful when you have .env-formatted content from a source that isn’t a file, like a configuration service response.
The dotenv Package
Before native .env support, the dotenv package was the de facto standard. You’ll still find it in many codebases:
// Using dotenvimport 'dotenv/config'
// That's it. process.env now has your .env values.console.log(process.env.DATABASE_URL)Or with more control:
import dotenv from 'dotenv'
dotenv.config({ path: '.env.production', override: true })The override: true option tells dotenv to overwrite existing environment variables, which differs from the native --env-file behavior where OS variables always win.
Native vs. dotenv: When to Use Which
| Feature | Native --env-file | dotenv |
|---|---|---|
| Zero dependencies | Yes | No |
| Multiline values | Yes (20.12+, quoted strings) | Yes |
Variable expansion (${VAR}) | No | Yes (dotenv-expand) |
| Multiple .env files | Yes (20.7.0+) | Yes (path array, dotenv 16.4+) |
| Optional file loading | Yes (--env-file-if-exists) | Manual check |
| Programmatic loading | Yes (20.12.0+ / 21.7.0+) | Yes |
| Env precedence (OS wins) | Yes | Configurable (override option) |
| Minimum Node.js | 20.6.0 | Any |
For new projects on Node.js 20.6 or later, the native --env-file flag is the recommended choice. It covers the vast majority of use cases with zero dependencies. Stick with dotenv if you need variable expansion (e.g., DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@localhost/mydb), or if you need to support older Node.js versions.
Validating Environment Variables
Reading environment variables is easy. The hard part is ensuring they’re actually correct. Without validation, a missing or malformed variable might not cause an error until it’s buried deep in your application logic, maybe during a database query that runs hours after startup, or in an edge case that only triggers in production.
The principle is simple: fail fast at startup with clear errors. If your app needs API_URL and it’s missing, crash immediately with a helpful message rather than starting up and failing silently later.
Manual Validation
For a zero-dependency approach, a simple validation function does the job:
function loadConfig() { const errors = []
const apiUrl = process.env.API_URL if (!apiUrl) { errors.push('API_URL is required') }
const port = Number(process.env.PORT || '3000') if (Number.isNaN(port) || port < 1 || port > 65535) { errors.push('PORT must be a number between 1 and 65535') }
const nodeEnv = process.env.NODE_ENV || 'development' if (!['development', 'production', 'test'].includes(nodeEnv)) { errors.push('NODE_ENV must be "development", "production", or "test"') }
if (errors.length > 0) { console.error('Configuration errors:') errors.forEach((err) => console.error(` - ${err}`)) process.exit(1) }
return Object.freeze({ apiUrl, port, nodeEnv, })}
export const config = loadConfig()This approach collects all errors before exiting, so developers see every missing or invalid variable at once instead of fixing them one at a time.
Validation with Zod
For larger applications, a schema validation library like Zod gives you more expressive validation with less code:
import { z } from 'zod'
const envSchema = z.object({ API_URL: z.string().url('API_URL must be a valid URL'), PORT: z.coerce.number().int().min(1).max(65535).default(3000), NODE_ENV: z .enum(['development', 'production', 'test']) .default('development'), API_KEY: z.string().min(1, 'API_KEY is required'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),})
const result = envSchema.safeParse(process.env)
if (!result.success) { console.error('Invalid environment variables:') for (const issue of result.error.issues) { console.error(` - ${issue.path.join('.')}: ${issue.message}`) } process.exit(1)}
export const config = Object.freeze(result.data)The z.coerce.number() parser handles the string-to-number conversion automatically, which is particularly useful for environment variables since they’re always strings.
Structuring Your Config Module
Regardless of which validation approach you choose, the pattern should be the same: create a single config.js module that validates all environment variables at import time and exports a frozen config object. Other modules import from config.js instead of accessing process.env directly.
import { config } from './config.js'
// Use typed, validated config everywhereconsole.log(`Starting server on port ${config.port}`)console.log(`Environment: ${config.nodeEnv}`)This centralization gives you a single place to see every environment variable your app needs, makes testing easier (you can mock the config module), and ensures that invalid configuration is caught at startup, not at runtime.
Security Best Practices
As we discussed earlier, environment variables end up holding your most sensitive data in many real-world setups: database credentials, API keys, encryption secrets. Ideally your most critical secrets live in a dedicated secrets manager, but whenever they do travel through the environment, handling them carelessly can undo all the benefits they’re supposed to provide.
Keep .env Files Out of Version Control
This is the most fundamental rule. Add .env to your .gitignore immediately:
.env.env.local.env.*.localInstead, commit a .env.example file that documents every variable your app needs, with placeholder values:
DATABASE_URL=postgres://user:password@localhost:5432/myappPORT=3000API_KEY=your-api-key-hereNODE_ENV=developmentThis serves as documentation for new team members and CI setup, without exposing real credentials.
Keep in mind that .env files are also a favorite target for attackers: vulnerabilities like path traversal are commonly exploited to read them straight off your server’s filesystem, which is one more reason not to keep production secrets in files.
Don’t Log Environment Variables
It’s tempting to add console.log(process.env) during debugging, but this dumps every secret to your logs. If those logs end up in a monitoring service, a log aggregator, or a crash report, your secrets are now exposed to anyone with access.
Instead, log selectively and redact sensitive values:
const SENSITIVE_KEYS = ['KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL']
function isSensitive(key) { return SENSITIVE_KEYS.some((s) => key.toUpperCase().includes(s))}
function safeLogEnv() { const safeEnv = {} for (const [key, value] of Object.entries(process.env)) { safeEnv[key] = isSensitive(key) ? '***REDACTED***' : value } console.log('Environment:', safeEnv)}
safeLogEnv()Child Process Inheritance
When you spawn a child process in Node.js, it inherits a copy of the parent’s entire environment by default. This includes all your secrets:
import { spawn } from 'node:child_process'
// DANGEROUS: child inherits ALL environment variables, including secretsconst child = spawn('node', ['untrusted-script.js'])
// SAFER: pass only what's neededconst safeChild = spawn('node', ['worker.js'], { env: { PATH: process.env.PATH, NODE_ENV: process.env.NODE_ENV, WORKER_ID: '1', // Intentionally NOT passing DATABASE_URL, API_KEY, etc. },})Secrets Management in Production
Environment variables are the right abstraction for passing configuration to your application, but storing and managing those secrets requires dedicated tooling in production:
- Docker: Use
-eflag,--env-file, or Docker Composeenvironment/env_filedirectives. AvoidENVdirectives in Dockerfiles for secrets since Docker layers are inspectable. - Kubernetes: Use ConfigMaps for non-sensitive config and Secrets for credentials. Mount them as environment variables in your pod spec.
- Cloud providers: AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager provide encrypted storage with access controls, rotation policies, and audit logs.
- CI/CD: GitHub Actions Secrets, GitLab CI Variables, and similar features inject secrets at build/deploy time without exposing them in code.
For more on running Node.js in containers, check out our guide on Node.js development with Docker and Docker Compose.
Node.js-Specific Environment Variables
Beyond your application’s custom variables, Node.js itself uses several environment variables to control runtime behavior. Knowing these can save you hours of debugging and help you tune performance.
NODE_ENV
NODE_ENV is probably the most widely known Node.js environment variable, but it’s important to understand that it’s a convention, not an official Node.js feature. It was popularized by Express, which uses it to toggle between development and production behavior (e.g., disabling verbose error pages in production).
const isDev = process.env.NODE_ENV !== 'production'
if (isDev) { console.log('Running in development mode') // Enable verbose logging, pretty-printing, etc.}Common values are development, production, and test. Many frameworks and libraries check NODE_ENV to toggle behavior: React disables development warnings in production, Express enables view caching, and bundlers like webpack apply optimizations.
A common misconception is that setting NODE_ENV=production magically makes your app faster. It doesn’t. It simply tells your code and your dependencies to behave as if they’re in production. If your dependencies don’t check NODE_ENV, it has no effect. And you should never use it alone for security decisions, it’s easy to set and provides no guarantees.
NODE_OPTIONS
NODE_OPTIONS lets you set Node.js CLI flags via an environment variable, which is especially useful in environments where you can’t control the node command directly:
# Increase memory limitNODE_OPTIONS='--max-old-space-size=4096' node app.js
# Load .env file via NODE_OPTIONSNODE_OPTIONS='--env-file=.env' node app.js
# Enable TypeScript stripping (Node.js 22.6+)NODE_OPTIONS='--experimental-strip-types' node app.js
# Combine multiple flagsNODE_OPTIONS='--max-old-space-size=4096 --env-file=.env' node app.jsNODE_DEBUG
NODE_DEBUG enables debug output from Node.js built-in modules. This is invaluable for troubleshooting without adding logging libraries or modifying code:
# Debug HTTP client behaviorNODE_DEBUG=http node app.js
# Debug multiple modulesNODE_DEBUG=http,net,tls node app.js
# Debug module resolutionNODE_DEBUG=module node app.js
# Debug timersNODE_DEBUG=timer node app.jsThe output is verbose but can reveal exactly what’s happening inside Node.js internals when something isn’t behaving as expected.
Controlling Colors and Warnings
These variables control the developer experience in terminals and CI environments:
FORCE_COLOR=1(or2,3): Force colored output even when the terminal doesn’t appear to support it. Useful in CI pipelines where color detection often fails.NO_COLOR: Disable all colored output. This is a cross-tool standard respected by many CLI tools beyond Node.js.NODE_DISABLE_COLORS=1: Node.js-specific color disable.NODE_NO_WARNINGS=1: Suppress all Node.js process warnings (experimental feature warnings, deprecation notices, etc.).NODE_REDIRECT_WARNINGS=file: Write warnings to a file instead of stderr, keeping your application output clean.
# Get colored test output in CIFORCE_COLOR=1 node --test
# Suppress warnings in productionNODE_NO_WARNINGS=1 node app.jsTLS and Certificate Variables
These variables control TLS/SSL behavior and are especially relevant for corporate networks and development environments:
NODE_EXTRA_CA_CERTS=file: Add additional CA certificates to the default set. Essential for corporate proxies and self-signed certificates.NODE_TLS_REJECT_UNAUTHORIZED: Set to0to disable TLS certificate validation.NODE_USE_SYSTEM_CA=1: Use the system CA certificate store instead of the bundled Mozilla CAs.SSL_CERT_DIR/SSL_CERT_FILE: OpenSSL certificate paths.
Performance and Runtime Variables
UV_THREADPOOL_SIZE: Controls the libuv thread pool size (default 4, max 1024). This affects async DNS lookups, filesystem operations, and crypto operations. If your app is heavy on these, increasing the pool can improve throughput.NODE_COMPILE_CACHE=dir: V8 code cache directory for faster startup. Node.js caches compiled code so subsequent runs skip the compilation step.TZ: Sets the timezone for the process. AffectsDate,Intl, and logging timestamps.
# Increase thread pool for I/O-heavy appsUV_THREADPOOL_SIZE=16 node app.js
# Enable compilation cacheNODE_COMPILE_CACHE=/tmp/node-cache node app.js
# Set timezoneTZ=UTC node app.jsOther Notable Node.js Variables
Here’s a reference table for additional variables worth knowing:
| Variable | Purpose |
|---|---|
NODE_PATH | Additional module lookup paths (mostly superseded by ESM) |
NODE_ICU_DATA | Custom ICU data for internationalization |
NODE_REPL_HISTORY | Path to REPL history file |
NODE_V8_COVERAGE=dir | Output directory for V8 code coverage data |
NODE_TEST_CONTEXT | Context value available in the built-in test runner |
NODE_PENDING_DEPRECATION=1 | Emit pending deprecation warnings |
NODE_RUN_SCRIPT_NAME | Set by node --run, contains the script name being executed |
NODE_USE_ENV_PROXY=1 | Use HTTP_PROXY/HTTPS_PROXY env vars for Node.js fetch requests |
For the complete and up-to-date reference, see the Node.js CLI documentation. You can also check your current Node.js version to know which features are available with our guide on how to check Node.js version.
Best Practices Summary
Here’s a consolidated checklist for working with environment variables in Node.js:
- Validate at startup, fail fast: Check all required variables before your app starts accepting requests. Report all errors at once, not one at a time.
- Centralize config access: Create a single config module. Other code imports from it, never from
process.envdirectly. - Parse types explicitly: Don’t rely on truthiness. Convert strings to numbers, booleans, and enums at the boundary.
- Use the native
--env-fileflag: Zero dependencies, built into Node.js 20.6+. - Never commit
.envfiles: Add them to.gitignore. Commit a.env.exampleinstead. - Don’t log the full environment: Redact sensitive keys. A stray
console.log(process.env)can leak secrets to your monitoring stack. - Use a secrets manager in production: Don’t rely on
.envfiles in production. Use your platform’s secrets management solution. - Document variables in
.env.example: New team members and CI pipelines need to know what variables are required. - Be aware of string coercion: Everything is a string.
"false"is truthy."3000"is not a number. - Control child process inheritance: Don’t pass secrets to child processes that don’t need them.
Summary
Here’s a quick reference for choosing the right approach:
| Approach | Best For | Node.js Version |
|---|---|---|
process.env | Reading variables already in the environment | All |
--env-file | Loading .env files with no dependencies | 20.6.0+ |
--env-file-if-exists | Optional .env files that may not exist | 22.9.0+ |
process.loadEnvFile() | Programmatic .env loading at runtime | 20.12.0+ / 21.7.0+ |
util.parseEnv() | Parsing .env-formatted strings | 20.12.0+ / 21.7.0+ |
dotenv | Variable expansion, older Node.js versions | Any |
Separating configuration from code is one of the most impactful things you can do for the maintainability and security of your applications. Environment variables give you a standard, portable way to do it, and they’re what make it possible to build your app once and ship the same artifact to every environment. With the native --env-file flag, modern Node.js makes it easier than ever to work with .env files without adding dependencies.
Whether you’re building a small script or a production service, the principles are the same: validate early, centralize access, parse types explicitly, and never let secrets leak into your code or logs.
Frequently Asked Questions
How do I access environment variables in Node.js?
Use the global process.env object. For example, process.env.API_URL returns the value of the API_URL variable. All values are coerced to strings, so you need to parse numbers with Number() and compare booleans explicitly (e.g., process.env.DEBUG === 'true'). The process.env object is available globally without any imports.
Does Node.js have built-in .env file support?
Yes, since Node.js 20.6.0 you can use the --env-file flag to load .env files without any dependencies. Run your app with node --env-file=.env app.js. Since 20.7.0, you can specify multiple files (later files override earlier ones), and since 21.7.0 (backported to 20.12.0) you can also use process.loadEnvFile() and util.parseEnv() for programmatic loading and parsing.
Do I still need the dotenv package in Node.js?
For new projects running Node.js 20.6 or later, the native --env-file flag covers most use cases with zero dependencies. However, dotenv still offers features the native implementation lacks, most notably variable expansion (${VAR} syntax via dotenv-expand) and the override option that lets .env files overwrite existing OS variables. Use dotenv if you need these features or need to support older Node.js versions.
Should I store secrets in environment variables in Node.js?
It’s common and convenient, but not ideal. Environment variables are inherited by child processes by default, visible in docker inspect output, readable from /proc/<pid>/environ on Linux, and often captured by crash reporters and logging tools. For production systems, prefer a dedicated secrets manager (like AWS Secrets Manager or HashiCorp Vault) or secrets mounted as files with restricted permissions. If you do pass secrets as environment variables, make sure they’re injected at runtime by your platform and never committed to version control.
What is NODE_ENV and should I use it?
NODE_ENV is a widely adopted convention (not an official Node.js feature) that indicates the runtime environment, typically set to "development" or "production". Many frameworks use it to toggle behavior: Express enables view caching, React disables dev warnings, and bundlers apply optimizations. You should use it as a convention, but never rely on it alone for security decisions since it’s trivially easy to set.
Why are all process.env values strings in Node.js?
Environment variables are strings at the operating system level. Node.js preserves this behavior, so process.env coerces everything to strings. The number 42 becomes "42", null becomes "null", and undefined becomes "undefined". This means you should always validate and parse values at application startup using Number(), explicit string comparisons, or a validation library like Zod.