TL;DR
cross-env sets environment variables cross-platform in npm scripts — cross-env NODE_ENV=production node server.js works on Windows, macOS, and Linux. env-cmd loads environment variables from .env files before running a command — env-cmd -f .env.production npm start. dotenv-cli is similar to env-cmd but uses dotenv under the hood — dotenv -e .env.production -- npm start. In 2026: cross-env for inline env vars in scripts, env-cmd or dotenv-cli for loading from .env files — though Node.js 21+ has built-in --env-file support.
Key Takeaways
- cross-env: ~8M weekly downloads — sets env vars inline, cross-platform, zero config
- env-cmd: ~2M weekly downloads — loads
.envfiles before commands, supports env-specific files - dotenv-cli: ~1M weekly downloads — wraps dotenv, loads
.envfiles, cascade support - The problem:
NODE_ENV=production node server.jsfails on Windows (PowerShell/CMD syntax differs) - cross-env solves the syntax problem —
cross-env NODE_ENV=productionworks everywhere - Node.js 21+ has
node --env-file=.env— may replace env-cmd/dotenv-cli for simple cases
The Problem
# This works on macOS/Linux:
NODE_ENV=production node server.js
# This FAILS on Windows CMD:
# 'NODE_ENV' is not recognized as an internal or external command
# This FAILS on Windows PowerShell:
# The term 'NODE_ENV=production' is not recognized
# Each platform has different syntax:
# Linux/macOS: VAR=value command
# Windows CMD: set VAR=value && command
# PowerShell: $env:VAR="value"; command
# Solution: cross-env normalizes this
cross-env NODE_ENV=production node server.js
# ✅ Works on all platforms
cross-env
cross-env — cross-platform env vars:
Setup
npm install -D cross-env
Usage in package.json
{
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
"test": "cross-env NODE_ENV=test vitest",
"start": "cross-env NODE_ENV=production PORT=3000 node dist/index.js",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=true eslint src/"
}
}
Multiple variables
{
"scripts": {
"start:staging": "cross-env NODE_ENV=production API_URL=https://staging-api.example.com PORT=3001 node dist/index.js"
}
}
With other tools
{
"scripts": {
"db:migrate": "cross-env DATABASE_URL=postgresql://localhost:5432/pkgpulse prisma migrate deploy",
"db:seed": "cross-env DATABASE_URL=postgresql://localhost:5432/pkgpulse tsx prisma/seed.ts"
}
}
cross-env-shell (for shell features)
{
"scripts": {
"greet": "cross-env-shell \"echo Hello $USER from $NODE_ENV\"",
"build:all": "cross-env-shell NODE_ENV=production \"npm run build:api && npm run build:web\""
}
}
env-cmd
env-cmd — load .env files:
Setup
npm install -D env-cmd
Basic usage
{
"scripts": {
"dev": "env-cmd -f .env.development tsx watch src/index.ts",
"start": "env-cmd -f .env.production node dist/index.js",
"test": "env-cmd -f .env.test vitest"
}
}
.env files
# .env.development
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/pkgpulse_dev
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
# .env.production
NODE_ENV=production
PORT=8080
DATABASE_URL=postgresql://prod-db:5432/pkgpulse
REDIS_URL=redis://prod-redis:6379
LOG_LEVEL=warn
# .env.test
NODE_ENV=test
PORT=3001
DATABASE_URL=postgresql://localhost:5432/pkgpulse_test
LOG_LEVEL=silent
Fallback files
{
"scripts": {
"start": "env-cmd -f .env.local --fallback node dist/index.js"
}
}
# --fallback uses .env if the specified file doesn't exist
# Loads .env.local if it exists, otherwise falls back to .env
.env-cmdrc (environment map)
// .env-cmdrc.json — all environments in one file:
{
"development": {
"NODE_ENV": "development",
"PORT": "3000",
"DATABASE_URL": "postgresql://localhost:5432/pkgpulse_dev"
},
"production": {
"NODE_ENV": "production",
"PORT": "8080",
"DATABASE_URL": "postgresql://prod-db:5432/pkgpulse"
},
"test": {
"NODE_ENV": "test",
"PORT": "3001"
}
}
{
"scripts": {
"dev": "env-cmd -e development tsx watch src/index.ts",
"start": "env-cmd -e production node dist/index.js",
"test": "env-cmd -e test vitest"
}
}
dotenv-cli
dotenv-cli — dotenv-powered env loading:
Setup
npm install -D dotenv-cli
Usage
{
"scripts": {
"dev": "dotenv -e .env.development -- tsx watch src/index.ts",
"start": "dotenv -e .env.production -- node dist/index.js",
"test": "dotenv -e .env.test -- vitest",
"db:migrate": "dotenv -e .env.production -- prisma migrate deploy"
}
}
Cascade (multiple files)
{
"scripts": {
"dev": "dotenv -e .env.development -e .env.local -- tsx watch src/index.ts"
}
}
# Load order: .env.development first, .env.local overrides
# .env.development: PORT=3000, DATABASE_URL=...
# .env.local: PORT=3001 (override for your machine)
# Result: PORT=3001
Variable expansion
# .env.production
BASE_URL=https://pkgpulse.com
API_URL=${BASE_URL}/api
CDN_URL=${BASE_URL}/static
# dotenv-cli expands ${} references:
# API_URL becomes https://pkgpulse.com/api
Node.js 21+ --env-file (Built-in)
# Node.js 21+ has native .env file support:
node --env-file=.env dist/index.js
# Multiple files:
node --env-file=.env --env-file=.env.local dist/index.js
# In package.json:
{
"scripts": {
"start": "node --env-file=.env.production dist/index.js",
"dev": "node --env-file=.env.development --watch src/index.ts"
}
}
Limitations of --env-file:
❌ No variable expansion (${VAR} not supported)
❌ No comments inline (# only at start of line)
❌ Requires Node.js 21+
✅ Zero dependencies
✅ Works with --watch
Feature Comparison
| Feature | cross-env | env-cmd | dotenv-cli | node --env-file |
|---|---|---|---|---|
| Inline env vars | ✅ | ❌ | ❌ | ❌ |
| Load .env files | ❌ | ✅ | ✅ | ✅ |
| Multiple .env files | ❌ | ✅ (fallback) | ✅ (cascade) | ✅ |
| Variable expansion | ❌ | ❌ | ✅ (${VAR}) | ❌ |
| Environment map | ❌ | ✅ (.env-cmdrc) | ❌ | ❌ |
| Cross-platform | ✅ (core feature) | ✅ | ✅ | ✅ |
| Dependencies | 0 | 0 | dotenv | 0 (built-in) |
| Node.js version | Any | Any | Any | 21+ |
| Weekly downloads | ~8M | ~2M | ~1M | built-in |
Recommended Setup for 2026
// For most projects — combine cross-env + dotenv-cli:
{
"scripts": {
"dev": "dotenv -e .env.development -- tsx watch src/index.ts",
"build": "cross-env NODE_ENV=production tsup",
"start": "dotenv -e .env.production -- node dist/index.js",
"test": "dotenv -e .env.test -- vitest",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=true eslint src/"
}
}
// Or if using Node.js 21+ — use built-in:
{
"scripts": {
"dev": "node --env-file=.env.development --watch src/index.ts",
"start": "node --env-file=.env.production dist/index.js"
}
}
When to Use Each
Use cross-env if:
- Setting env vars inline in npm scripts (
NODE_ENV=production) - Need cross-platform compatibility (Windows team members)
- Don't need .env file loading — just inline variables
Use env-cmd if:
- Loading environment from
.envfiles per environment - Want
.env-cmdrcfor all environments in one config file - Need fallback file support
Use dotenv-cli if:
- Loading from
.envfiles with variable expansion (${VAR}) - Need cascade (multiple .env files, later files override earlier)
- Already using dotenv elsewhere in your project
Use node --env-file if:
- Node.js 21+ is your minimum version
- Simple .env loading without variable expansion
- Zero dependency preference
Security: Environment Variables and .env File Best Practices
These packages handle environment variables, which often contain secrets. Getting security right matters:
Never commit .env files with real secrets. The .gitignore should list:
.env
.env.local
.env.*.local
.env.production
.env.staging
What's safe to commit:
# .env.example — template with placeholder values (commit this)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_KEY
# .env.development — can commit if values are non-sensitive dev defaults
NODE_ENV=development
PORT=3000
# Don't commit real API keys even for dev — use .env.local for personal overrides
For CI/CD secrets: Don't load them from .env files. Use your CI platform's secrets management:
- GitHub Actions:
${{ secrets.STRIPE_SECRET_KEY }}in workflow YAML - GitLab CI:
$STRIPE_SECRET_KEYfrom project CI/CD variables - Vercel: environment variables in project settings
cross-env and dotenv-cli are for local development convenience, not production secret management. In production, environment variables should be injected by the platform, not loaded from files.
Real-World Patterns for 2026 Projects
The most effective setup for a full-stack TypeScript project combines these tools:
{
"scripts": {
// Development: load dev env, watch for changes
"dev": "dotenv -e .env.development -- tsx watch src/index.ts",
// Testing: isolated test database
"test": "dotenv -e .env.test -- vitest run",
"test:watch": "dotenv -e .env.test -- vitest",
// Database management (needs production URL from CI env, not file)
"db:migrate:dev": "dotenv -e .env.development -- prisma migrate dev",
"db:migrate:prod": "prisma migrate deploy",
// Note: db:migrate:prod uses env vars from CI/CD platform, not a file
// Build: inline env vars for the build process
"build": "cross-env NODE_ENV=production tsup",
// Production start: env vars injected by platform, no file loading
"start": "node dist/index.js"
}
}
The pattern: use dotenv-cli or env-cmd for development and test environments (where loading from files is convenient and safe), and cross-env for inline variables during build steps. Don't use any of these tools in production — let the deployment platform inject secrets.
The Node.js --env-file Trajectory
Node.js 20+ introduced --env-file as an experimental flag, and it became stable in Node.js 21. In 2026, with Node.js 22 LTS, --env-file is fully stable and supported. The question for new projects: should you use the built-in flag or a third-party tool?
The built-in --env-file handles the most common use case (load a .env file) with zero dependencies. For the majority of projects, this is sufficient:
# package.json
"dev": "node --env-file=.env.development --watch src/index.ts"
"test": "node --env-file=.env.test --import tsx/esm src/index.ts"
The cases where you still need dotenv-cli or env-cmd in 2026:
- Variable expansion —
${BASE_URL}/apiin.envfiles works with dotenv-cli but not--env-file - Non-node runtimes — Bun has its own
.envloading; thecross-envapproach works with any runtime - Complex environment merging — env-cmd's
.env-cmdrclets you define multiple environment profiles in a single file, which--env-filedoesn't support - Legacy projects — migrating from env-cmd/dotenv-cli to
--env-fileis a minor effort; no urgent reason to change if things work
The trend is clear: --env-file will replace dotenv-cli and env-cmd for new projects that can target Node.js 21+. cross-env remains useful for inline env vars in npm scripts regardless of Node.js version.
Community Adoption in 2026
Weekly npm downloads reflect very different adoption patterns (early 2026 estimates):
| Package | Weekly Downloads | GitHub Stars | Status |
|---|---|---|---|
| cross-env | ~8M | 6,200+ | Maintenance mode |
| env-cmd | ~2M | 2,200+ | Actively maintained |
| dotenv-cli | ~1M | 600+ | Actively maintained |
cross-env's 8M weekly downloads are largely legacy. Kent C. Dodds placed the package in maintenance mode — it is considered complete. It does exactly one thing (cross-platform env var syntax), does it well, and will not change. The high download count reflects how deeply embedded it is in years of project templates and docs.
The maintenance mode status means: no new features, security patches only, and eventual deprecation signals. For most usage patterns cross-env handles, Node.js 21+ --env-file or the package manager's environment handling is an adequate replacement.
env-cmd and dotenv-cli both see active development and are suitable for complex multi-environment setups. If your project uses env-cmd today and it works, there is no urgency to change.
Performance Comparison
These packages add startup overhead before your actual process starts. The differences are small but visible in fast-iterating dev workflows:
| Tool | Startup overhead | Process spawning | Memory |
|---|---|---|---|
| cross-env | ~15-30ms | Spawns subprocess | Minimal |
| env-cmd | ~40-80ms | Spawns subprocess | Minimal |
| dotenv-cli | ~40-80ms | Spawns subprocess | Minimal |
| node --env-file | ~0ms | No extra process | None |
cross-env is faster than env-cmd and dotenv-cli because it does less work — it only manipulates the env object before spawning the child process, with no file I/O. The overhead is barely perceptible for one-off commands but adds up in test suites that spawn processes frequently.
node --env-file is the fastest option because it is built into the Node.js startup sequence — no npm script subprocess spawning at all. If you run node --env-file=.env src/index.ts via tsx or ts-node, the overhead is zero compared to dotenv-cli.
Migration Guide
Migrating from cross-env to node --env-file (Node.js 21+):
// Before:
{
"scripts": {
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
"build": "cross-env NODE_ENV=production tsup"
}
}
// After (inline vars still need cross-env — no direct replacement):
// For .env file loading, --env-file works:
{
"scripts": {
"dev": "node --env-file=.env.development --import tsx/esm --watch src/index.ts",
"build": "cross-env NODE_ENV=production tsup"
}
}
// Note: cross-env is still needed for inline vars in npm scripts
// --env-file only loads from files, not inline
Migrating from env-cmd to dotenv-cli:
// Before (env-cmd):
{
"scripts": {
"dev": "env-cmd -f .env.development tsx watch src/index.ts",
"test": "env-cmd -f .env.test vitest"
}
}
// After (dotenv-cli):
{
"scripts": {
"dev": "dotenv -e .env.development -- tsx watch src/index.ts",
"test": "dotenv -e .env.test -- vitest"
}
}
// Key difference: dotenv-cli uses -- before the command (POSIX separator)
// and -e instead of -f for the file flag
Migrating from env-cmd to node --env-file:
// Before:
{
"scripts": {
"dev": "env-cmd -f .env.development tsx watch src/index.ts"
}
}
// After (requires Node.js 21+ and tsx 4+):
{
"scripts": {
"dev": "node --env-file=.env.development --import tsx/esm --watch src/index.ts"
}
}
// If using tsx as CLI: tsx --env-file=.env.development watch src/index.ts
// (tsx 4+ proxies Node.js flags)
Docker and Container Environment Patterns
These packages are primarily for local development and CI workflows — in Docker containers and Kubernetes deployments, environment variables are typically injected by the orchestration platform rather than loaded from files. Understanding where each tool fits in the container lifecycle helps avoid misuse.
In Docker, the canonical pattern is to never include .env files in the container image. Instead, environment variables are passed at runtime via docker run --env-file .env.production, Kubernetes Secrets mounted as environment variables, or cloud platform environment configuration. cross-env has no role in this workflow — it manipulates npm script environment before spawning a process, which is irrelevant inside a running container. dotenv-cli and env-cmd are similarly out of scope for production containers, though they remain useful in Docker-based local development setups where developers run services via docker-compose and want to inject environment variables from local .env files into the compose configuration.
The one container use case where these tools remain relevant is during multi-stage Docker builds. A build step that runs npm run build needs environment variables (base URLs, feature flags, public API keys) that differ between environments. Using cross-env inline in the npm run build script — cross-env NEXT_PUBLIC_API_URL=https://api.example.com next build — is a clean way to inject these build-time variables as Docker build arguments, using ARG and ENV in the Dockerfile and --build-arg in the build command.
Monorepo setups introduce additional complexity where environment variable files need to be shared across packages or scoped per workspace. dotenv-cli's cascade feature handles this elegantly: a root .env file defines shared variables, and each workspace can have a workspace-specific .env.local that overrides specific values. env-cmd's .env-cmdrc JSON format naturally supports workspace-specific environment definitions within a single file, making it convenient for monorepos managed with npm workspaces or pnpm workspaces where each package may need distinct environment configurations for development and testing.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on cross-env v7.x, env-cmd v10.x, dotenv-cli v7.x, and Node.js 22.
Compare environment management package health on PkgPulse. Also see best env variable management tools for Node.js for a broader comparison and dotenv vs t3-env vs envalid for validation-focused tools.
Related: cac vs meow vs arg 2026.