Environment Variables
Environment variables are key-value pairs that live outside your source code, in the operating system process that runs your program. Node.js exposes every one of them through a single global object, process.env. They are the standard way to configure a Node application — database URLs, API keys, ports, feature flags — without hardcoding secrets or environment-specific values into your files, so the exact same code can run in development, staging, and production just by changing what surrounds it.
Overview: How process.env Works
process is a global object available in every Node.js module without an import. One of its properties, process.env, is a plain JavaScript object that Node populates once, at process startup, by copying every variable from the operating system’s environment block into it. That copy happens before any of your code runs, which is why process.env.PORT is already available on the very first line of your script.
Three things about process.env surprise almost everyone the first time:
Every value is a string
Even if a variable looks like a number or a boolean in the shell (PORT=3000, DEBUG=true), Node stores it as the string "3000" or "true". There is no automatic type coercion. If you need a number or boolean in your code, you must convert it yourself.
Changes are local to the current process
You can assign to process.env.SOMETHING = "value" at runtime, and it will read back correctly for the rest of that process’s life, and it will be inherited by any child processes you spawn with node:child_process. But it never reaches back out to the shell that launched Node, and it disappears the moment the process exits. Environment variables flow one direction: parent shell → Node process → child processes.
Node itself sets very few of them
NODE_ENV is not a built-in Node.js feature — it is a convention popularized by frameworks like Express, and libraries check it, but the Node runtime does not set it or treat it specially. You have to set it yourself. Meanwhile, variables like PATH, HOME, or USER are simply inherited from the operating system because Node copies the entire environment block, not a curated subset.
This design lines up with the “twelve-factor app” principle of storing configuration in the environment rather than in code: the same built artifact can move from a laptop to a CI runner to a production server, picking up different behavior purely from the variables set around it.
Syntax
| Form | What it does |
|---|---|
process.env.NAME |
Reads a variable. Returns a string, or undefined if it is not set. |
process.env.NAME = "value" |
Sets or overwrites a variable for the current process (and its children). The value is coerced to a string. |
delete process.env.NAME |
Removes a variable from the current process’s environment. |
Object.keys(process.env) |
Lists every variable name currently set. |
node --env-file=.env app.js |
Node 20.6+: loads a .env file into process.env before your script starts running — no package required. |
Examples
Example 1: Reading variables with a fallback
The nullish coalescing operator ?? is the idiomatic way to supply a default when a variable isn’t set, because it only falls back on null/undefined, not on falsy-but-valid values like "0" or "".
// app.js
const PORT = process.env.PORT ?? 3000;
const NODE_ENV = process.env.NODE_ENV ?? "development";
console.log(`Starting server in ${NODE_ENV} mode on port ${PORT}`);
if (NODE_ENV === "production") {
console.log("Production optimizations enabled");
} else {
console.log("Running with verbose development logging");
}
Run it directly and it falls back to the defaults:
node app.js
Output:
Starting server in development mode on port 3000
Running with verbose development logging
Now set variables inline before the command. On macOS/Linux you can prefix the command; on Windows PowerShell you set them as separate statements:
# macOS / Linux
PORT=4000 NODE_ENV=production node app.js
# Windows PowerShell
$env:PORT=4000; $env:NODE_ENV="production"; node app.js
Output:
Starting server in production mode on port 4000
Production optimizations enabled
Example 2: Loading a .env file with dotenv
Setting variables inline works for quick tests, but real projects keep local configuration in a .env file so it doesn’t have to be retyped every run. The dotenv package reads that file and copies its entries into process.env.
npm install dotenv
Create a .env file next to your script (never commit this file to version control):
DATABASE_URL=postgres://localhost:5432/mydb
API_KEY=sk_test_12345
// config.js
require("dotenv").config();
const dbUrl = process.env.DATABASE_URL;
const apiKey = process.env.API_KEY;
if (!dbUrl || !apiKey) {
throw new Error("Missing required environment variables: DATABASE_URL, API_KEY");
}
console.log(`Connecting to ${dbUrl}`);
module.exports = { dbUrl, apiKey };
dotenv.config() reads the file synchronously (it’s a one-time startup cost, so blocking briefly is fine here) and assigns each key it finds to process.env, but only if that key isn’t already set — real environment variables from the shell or a hosting platform always win over the file.
Example 3: The built-in –env-file flag
Since Node 20.6, you don’t need a package for the basic case. The --env-file flag loads a file into process.env before your script even starts:
node --env-file=.env app.js
This covers simple KEY=value files without any dependency. Reach for dotenv instead when you need multiple cascading files (.env.local, .env.production) or you’re supporting older Node versions.
Under the Hood: Order of Operations
Understanding exactly when process.env gets populated avoids a whole category of “why is this undefined” bugs:
- When you type
node app.js, the OS hands the new Node process a copy of the shell’s environment block before a single line of JavaScript executes.process.envis already fully populated by the time your module’s top-level code runs. - If you use
node --env-file=.env app.js, Node reads and merges the file intoprocess.envas part of process bootstrap, before loading your entry module at all. - If you use
dotenvinstead, nothing happens until your code explicitly callsrequire("dotenv").config(). Everything above that call sees the environment as it was when the shell launched Node — without the file’s values. This is exactly why the call must be the very first thing that runs, ahead of any other module that readsprocess.envat import time. - Assignments like
process.env.FOO = "bar"take effect immediately and are visible to any code that readsprocess.env.FOOafterward, including child processes spawned later — but never to code that already ran and cached the old value in a variable.
Common Mistakes
Mistake 1: Treating a string as a boolean
Every value in process.env is a non-empty string as long as the variable is set at all — even the string "false" is truthy in JavaScript. This bites people constantly:
// DEBUG is set to the string "false" in the shell
if (process.env.DEBUG) {
console.log("Debug mode is ON");
}
// Prints "Debug mode is ON" even though DEBUG=false!
Compare against the actual string value you expect, not the variable’s mere presence:
if (process.env.DEBUG === "true") {
console.log("Debug mode is ON");
}
The same trap applies to numbers: process.env.MAX_RETRIES + 1 concatenates strings and produces "31" instead of adding 1 to 3. Always convert explicitly with Number(...) or parseInt(..., 10) before doing arithmetic.
Mistake 2: Loading dotenv too late, and skipping validation
Because dotenv.config() only affects code that runs after it, importing a module that reads process.env before calling config() silently gets undefined:
const db = require("./db"); // reads process.env.DATABASE_URL immediately — but it isn't set yet!
require("dotenv").config();
Call config() first, before any other local module that depends on those variables, and fail fast if something required is missing rather than limping along with undefined:
require("dotenv").config(); // must run before anything that needs the variables
const required = ["DATABASE_URL", "API_KEY"];
for (const name of required) {
if (!process.env[name]) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
const db = require("./db");
Failing at startup with a clear error is far easier to debug than a mysterious crash three requests into production because a secret was never set.
Best Practices
- Never hardcode secrets, API keys, or connection strings in source files — put them in environment variables instead.
- Add
.envto.gitignoreso it never reaches version control; commit a.env.examplewith placeholder values instead so teammates know what’s required. - Validate required environment variables at startup and throw immediately if any are missing, rather than letting
undefinedpropagate into your application logic. - Remember every value is a string — explicitly convert numbers with
Number()/parseInt()and booleans by comparing against the exact expected string. - Use the
NODE_ENVconvention (development,production,test) consistently, since many popular libraries check it to decide how verbose or optimized to be. - Never log
process.envin full or include secret values in error messages — both can end up in log aggregators that other people can read. - Prefer the built-in
--env-fileflag for simple local setups on Node 20.6+; reach fordotenvwhen you need multiple environment-specific files. - In production, set variables through your hosting platform’s configuration (not a checked-in
.envfile) so secrets never live in the deployed artifact.
Practice Exercises
- Write a script
greet.jsthat reads aNAMEenvironment variable and printsHello, <NAME>!, defaulting to"stranger"if it isn’t set. Run it both with and withoutNAMEset inline on the command line. - Create a
.envfile with aMAX_UPLOAD_MBvariable set to10. Write a script that reads it, converts it to a number, and prints whether a hypothetical 15 MB file would be rejected. Load the file first withdotenv, then again usingnode --env-filewithout the package, and compare. - Write a small
validateEnv()function that takes an array of required variable names, checks each one againstprocess.env, and throws a single error listing every missing name (not just the first one it finds). Test it by running your script with some variables unset.
Summary
process.envis a global object populated once at startup from the operating system’s environment, with every value stored as a string.- Setting
process.env.NAME = valueonly affects the current process and its children, and never persists after the process exits. dotenvand Node’s built-in--env-fileflag both load a.envfile intoprocess.env, butdotenvonly takes effect after itsconfig()call runs, so that call must come first.- Treat string values carefully: compare booleans against exact strings and convert numbers explicitly before doing arithmetic.
- Validate required variables at startup and fail fast, keep secrets out of version control and logs, and configure production through your hosting platform rather than a shipped
.envfile.
