npm 12 ๐ Environment Variables in npm Scripts
Environment variables are the mechanism by which npm scripts receive configuration that varies by machine, by environment, or by deployment target. A script that reads process.env.NODE_ENV or process.env.API_URL is not hardcoded to a single setup; it adapts based on what the surrounding shell or .env file provides. The complication is that setting environment variables in a script is not portable. The syntax that works in Bash (VAR=value command) fails in Windows cmd.exe, and the syntax that works in PowerShell ($env:VAR="value") fails everywhere else . This chapter covers the three approaches to providing variables to npm scriptsโinline assignment, .env files loaded by the script, and cross-platform wrappersโand the trade-offs that determine which one belongs in which situation.
Key point: Inline environment variable assignment in package.json scripts is POSIX-only. If the project is developed or CI-built on Windows, the script must use cross-env or a .env loader, or the build will fail on one platform and succeed on the other.
Why environment variables in npm scripts matter
The portability problem. A package.json is shared across every developer’s machine and every CI runner. If a script contains NODE_ENV=production node build.js, it works on macOS and Linux and fails on Windows cmd.exe with “'NODE_ENV' is not recognized as an internal or external command.” The failure is not in the code; it is in the shell syntax. cross-env exists specifically to translate POSIX-style assignment into the correct syntax for the platform running the script .
The secret management problem. Database URLs, API keys, and tokens should not be committed to package.json or source control. A .env file kept outside version control provides a place for these values, and a loader such as dotenv reads the file into process.env at runtime . The script references process.env.DATABASE_URL; the value lives in a file that is gitignored.
The environment switching problem. A project typically needs different configuration in development, test, and production. One approach is to set NODE_ENV inline and let the application read it to select a configuration object. Another is to maintain .env.development, .env.test, and .env.production files and load the appropriate one based on an inline flag . Both approaches rely on npm scripts passing the discriminatorโNODE_ENV or a similar variableโinto the process.
The CI reproducibility problem. A CI runner may have environment variables set at the system level, injected by the CI platform, or absent entirely. Scripts that assume a variable exists fail in CI with no clear error. Scripts that provide a default (process.env.PORT || 3000) are more resilient. Scripts that fail fast with a clear message when a required variable is missing are more debuggable. The choice between defaulting and failing is part of the design.
a. Inline Assignment and the Portability Failure
The simplest way to set an environment variable for a single script is to prefix the command with the assignment. On POSIX systems, VAR=value command sets VAR for that command’s duration and does not leak it into the parent shell. In package.json, this is how NODE_ENV is set for build and test scripts.
{
"scripts": {
"build": "NODE_ENV=production node build.js",
"test": "NODE_ENV=test vitest"
}
}
This works on macOS, Linux, and in CI runners that use a POSIX shell. It fails on Windows cmd.exe and PowerShell, where the syntax is different. The error is immediate and the cause is not always obvious to a developer who has only used one platform. The cross-env package exists to eliminate this class of failure .
{
"scripts": {
"build": "cross-env NODE_ENV=production node build.js"
}
}
The cross-env wrapper reads the POSIX-style assignment, detects the platform, and executes the command with the variable set correctly for that platform. It works on Windows cmd.exe, PowerShell, and POSIX shells without modification to the script . The package should be installed as a devDependency because it is only needed at development and build time, not at runtime.
Multiple variables can be set in one invocation. The assignments precede the command, and all of them are available to the process.
{
"scripts": {
"build:staging": "cross-env NODE_ENV=staging API_URL=https://staging.example.com node build.js"
}
}
For commands that require shell interpretation of the variablesโpipes, &&, or variable expansion inside a quoted command stringโcross-env-shell is the appropriate variant. It spawns the command through a shell rather than executing it directly, which allows shell syntax to work on Windows as well . The rule of thumb is to use cross-env for simple commands and cross-env-shell only when shell features are required.
b. .env Files and Loaders
A .env file stores key-value pairs outside the codebase. The format is simple: one assignment per line, with no quoting required for most values. A loader reads the file and assigns the values to process.env before the application logic runs.
# .env โ never commit this file
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=sk_test_abc123
LOG_LEVEL=debug
The dotenv package is the most common loader. It is installed as a dependency and invoked at the top of the application entry point, before any code that reads process.env.
// src/index.js โ load .env before anything else
import 'dotenv/config';
// Now process.env.DATABASE_URL is available
const dbUrl = process.env.DATABASE_URL;
The dotenv/config import loads .env from the current working directory automatically. For explicit control over the path, the function form is used.
import dotenv from 'dotenv';
dotenv.config({ path: './config/.env.local' });
The .env file must be added to .gitignore. Committing it exposes secrets in the repository history, where they remain even after the file is deleted. A common pattern is to commit a .env.example file with placeholder values and no secrets, so that new contributors know which variables are required.
# .env.example โ committed to version control
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=your_key_here
Node.js itself gained native .env file support via the --env-file flag, which eliminates the need for dotenv in some cases. The flag loads the specified file before executing the script .
{
"scripts": {
"start": "node --env-file=.env src/index.js"
}
}
The native flag is available in Node.js 20.6 and later. For projects that support older Node versions or need features like variable expansion inside the .env file, dotenv remains necessary.
c. Combining Inline Flags with .env Files
A common pattern is to use inline assignment to select which .env file to load, then let the loader read the selected file. This keeps the discriminator (NODE_ENV) in the script while keeping the secrets out of it.
{
"scripts": {
"dev": "cross-env NODE_ENV=development node --env-file=.env.development src/index.js",
"start": "cross-env NODE_ENV=production node --env-file=.env.production src/index.js"
}
}
The application reads NODE_ENV to decide which configuration path to take, and the loader provides the values for that environment. The .env.development and .env.production files are both gitignored, and both have committed .example counterparts.
When dotenv is used instead of the native flag, the path is selected in code rather than in the script.
// src/config.js โ select .env file based on NODE_ENV
import dotenv from 'dotenv';
const envFile = process.env.NODE_ENV === 'production'
? '.env.production'
: '.env.development';
dotenv.config({ path: envFile });
This approach places the selection logic in JavaScript rather than in the package.json script. It is more verbose but allows the selection to depend on multiple factors, not just a single inline variable.
Complete Example Session
// ============================================
// PART 1: THE PROBLEM โ POSIX-ONLY SCRIPT
// ============================================
// package.json โ works on macOS/Linux, fails on Windows
{
"scripts": {
"build": "NODE_ENV=production node build.js"
}
}
// ============================================
// PART 2: CROSS-ENV FIX
// ============================================
// Install cross-env as a dev dependency
// npm install --save-dev cross-env
{
"scripts": {
"build": "cross-env NODE_ENV=production node build.js"
}
}
// ============================================
// PART 3: MULTIPLE VARIABLES
// ============================================
{
"scripts": {
"build:staging": "cross-env NODE_ENV=staging API_URL=https://staging.example.com node build.js"
}
}
// ============================================
// PART 4: SHELL-DEPENDENT COMMANDS
// ============================================
// Use cross-env-shell when the command needs shell features
{
"scripts": {
"greet": "cross-env-shell GREETING=Hi \"echo $GREETING && node app.js\""
}
}
// ============================================
// PART 5: .ENV FILE FOR SECRETS
// ============================================
// .env โ gitignored
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=sk_test_abc123
// ============================================
// PART 6: LOADING WITH DOTENV
// ============================================
// src/index.js
import 'dotenv/config';
const dbUrl = process.env.DATABASE_URL;
// ============================================
// PART 7: NODE NATIVE .ENV LOADING
// ============================================
// Node 20.6+ supports --env-file
{
"scripts": {
"start": "node --env-file=.env src/index.js"
}
}
// ============================================
// PART 8: PER-ENVIRONMENT .ENV FILES
// ============================================
{
"scripts": {
"dev": "cross-env NODE_ENV=development node --env-file=.env.development src/index.js",
"start": "cross-env NODE_ENV=production node --env-file=.env.production src/index.js"
}
}
// ============================================
// PART 9: GITIGNORE THE SECRETS
// ============================================
// .gitignore
.env
.env.*
!.env.example
// ============================================
// PART 10: DEFAULT VALUES IN CODE
// ============================================
// src/config.js โ resilient to missing variables
const port = process.env.PORT || 3000;
const logLevel = process.env.LOG_LEVEL || 'info';
The ten parts trace the progression from the portability failure through cross-env, multiple variables, shell-dependent commands, .env files, dotenv, the native --env-file flag, per-environment files, gitignore rules, and default values in code.
Quick Reference
Approaches Compared
| Approach | Portability | Secrets in Repo | Best For |
|---|---|---|---|
Inline VAR=value | POSIX only | No | Simple projects, Linux CI |
cross-env | All platforms | No | Cross-platform teams |
.env + dotenv | All platforms | No (gitignored) | Local secrets |
node --env-file | Node 20.6+ | No (gitignored) | Modern Node, no dependency |
cross-env Usage
| Scenario | Command |
|---|---|
| Single variable | cross-env NODE_ENV=production node app.js |
| Multiple variables | cross-env A=1 B=2 node app.js |
| Shell features needed | cross-env-shell "echo $VAR && node app.js" |
| Install | npm install --save-dev cross-env |
.env Loading
| Method | Code |
|---|---|
dotenv auto | import 'dotenv/config' |
dotenv explicit | dotenv.config({ path: '.env.local' }) |
| Node native | node --env-file=.env app.js |
Best Practices
โ Do This:
// Use cross-env for cross-platform compatibility
"build": "cross-env NODE_ENV=production node build.js" // โ
# Add .env to .gitignore
echo ".env" >> .gitignore # โ
# Commit .env.example with placeholders
# .env.example โ no real secrets # โ
// Provide defaults for optional variables
const port = process.env.PORT || 3000; // โ
// Use native --env-file on Node 20.6+
"start": "node --env-file=.env src/index.js" // โ
โ Don’t Do This:
// POSIX-only inline assignment in a shared package.json
"build": "NODE_ENV=production node build.js" // โ
# Commit .env with real secrets
git add .env // โ
// Read variables without defaults or validation
const port = process.env.PORT; // โ
// Put secrets directly in package.json scripts
"start": "API_KEY=sk_live_abc123 node app.js" // โ
// Use cross-env-shell for simple commands
"build": "cross-env-shell NODE_ENV=production node build.js" // โ (overhead)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Script fails on Windows | Inline VAR=value syntax | Use cross-env |
| Variable is undefined at runtime | .env not loaded or wrong path | Check loader invocation and path |
| Secrets exposed in git history | .env committed | Add to .gitignore, rotate secrets |
| CI build fails with missing variable | No default and variable not set | Provide default or CI secret |
dotenv loads too late | Imported after code that reads process.env | Import at entry point, before other imports |
cross-env-shell hangs on Ctrl+C | Shell behavior on Windows | Use cross-env for simple commands |
Real-World Examples
1. Cross-Platform Build
"build": "cross-env NODE_ENV=production node build.js"
2. Multiple Variables
"build:staging": "cross-env NODE_ENV=staging API_URL=https://staging.example.com node build.js"
3. Load .env with dotenv
import 'dotenv/config';
4. Native Node .env Loading
"start": "node --env-file=.env src/index.js"
5. Per-Environment Files
"dev": "cross-env NODE_ENV=development node --env-file=.env.development src/index.js"
6. Default Port
const port = process.env.PORT || 3000;
7. Gitignore Secrets
echo ".env" >> .gitignore
8. Example File
# .env.example
API_KEY=your_key_here
9. Shell-Dependent Command
"greet": "cross-env-shell GREETING=Hi \"echo $GREETING && node app.js\""
10. Fail Fast on Missing Variable
if (!process.env.API_KEY) throw new Error('API_KEY required');
Visual
The Portability Failure
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ package.json โ
โ "build": "NODE_ENV=production node build.js" โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ macOS / Linux โ โ Windows cmd.exe โ โ
โ โ โ โ โ โ
โ โ VAR=value โ โ set VAR=value โ โ
โ โ command โ โ command โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ SUCCESS โ โ FAILURE โ โ
โ โ โ โ 'NODE_ENV' is โ โ
โ โ โ โ not recognized โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The script is identical. The shell is different. โ
โ cross-env translates the assignment for the platform. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
cross-env Translation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ "cross-env NODE_ENV=production node build.js" โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ cross-env reads โ โ
โ โ POSIX syntax โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ โ
โ โผ โผ โผ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ Linux/macOS โ โ Windows cmd โ โ PowerShell โ โ
โ โ โ โ โ โ โ โ
โ โ NODE_ENV= โ โ set NODE_ โ โ $env:NODE_ โ โ
โ โ production โ โ ENV=prod... โ โ ENV="prod" โ โ
โ โ node build โ โ node build โ โ node build โ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ
โ One script, three platforms, no conditional logic. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
.env Loading Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ .env file (gitignored) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ DATABASE_URL=postgresql://... โ โ
โ โ API_KEY=sk_test_abc123 โ โ
โ โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Loader (dotenv or --env-file) โ โ
โ โ Reads file, assigns to process.env โ โ
โ โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Application code โ โ
โ โ process.env.DATABASE_URL โ โ
โ โ process.env.API_KEY โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The script does not contain the secret. โ
โ The file does, and the file is not committed. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Inline Flag + .env Combination
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ "cross-env NODE_ENV=production node --env-file=.env.prod" โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ NODE_ENV โ Set by cross-env (inline) โ
โ โ =production โ Selects behavior in code โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ --env-file โ Loads secrets from file โ
โ โ .env.production โ DATABASE_URL, API_KEY, etc. โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Application โ Both sources merged into โ
โ โ process.env โ process.env โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Inline: the discriminator (which environment). โ
โ File: the secrets (what changes per environment). โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Inline assignment | VAR=value command โ POSIX only |
| Cross-platform wrapper | cross-env VAR=value command |
| Shell-dependent variant | cross-env-shell "command" |
.env loader (package) | dotenv โ import 'dotenv/config' |
.env loader (native) | node --env-file=.env (Node 20.6+) |
| Secrets protection | Add .env to .gitignore |
| Example file | .env.example with placeholders, committed |
| Default values | process.env.PORT || 3000 |
| Per-environment | NODE_ENV=production node --env-file=.env.production |
Key takeaways:
- Inline assignment is not portable.
NODE_ENV=production node app.jsworks in POSIX shells and fails in Windowscmd.exe.cross-envtranslates the assignment for the platform running the script . cross-envversuscross-env-shell. Usecross-envfor simple commands. Usecross-env-shellonly when the command contains shell featuresโpipes,&&, or variable expansion inside a quoted string ..envfiles keep secrets out ofpackage.json. The file is gitignored, the script referencesprocess.env.VAR, and a loader reads the file before the application runs. The native--env-fileflag in Node 20.6+ eliminates thedotenvdependency for simple cases .- The discriminator and the secrets can live in different places.
NODE_ENVcan be set inline viacross-envto select which.envfile to load. The environment is in the script; the secrets are in the gitignored file. - Provide defaults or fail fast.
process.env.PORT || 3000prevents a missing optional variable from crashing the process. For required variables, an explicit check with a clear error message is better than aundefinedthat surfaces later.
Remember: Environment variables in npm scripts are a portability problem disguised as a configuration problem. The values themselves are simple; the mechanism for passing them differs by shell, by platform, and by Node version. cross-env solves the shell difference. .env files solve the secret management difference. The native --env-file flag solves the dependency difference for modern Node. The remaining decision is which variables belong in the script (the discriminator) and which belong in the file (the secrets). Keeping that boundary clear is what makes a package.json that works on every developer’s machine and every CI runner without modification.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!