config, dotenv, dotenv-expand, and dotenv-safe are all Node.js packages designed to manage application configuration and environment variables, but they approach the problem differently. config provides a hierarchical, file-based configuration system that supports multiple environments through structured JSON, YAML, or JS files. dotenv loads environment variables from a .env file into process.env, offering a simple flat key-value approach. dotenv-expand extends dotenv by enabling variable interpolation within .env files (e.g., URL=http://${HOST}:${PORT}). dotenv-safe enhances dotenv with validation to ensure required environment variables are present, throwing errors if expected keys are missing. All four are commonly used in backend or full-stack JavaScript applications to separate configuration from code.
Managing environment-specific settings is a foundational concern in any production-grade application. The four packages under review — config, dotenv, dotenv-expand, and dotenv-safe — all aim to solve this problem but with fundamentally different philosophies, feature sets, and integration models. Let’s break down how they work, where they overlap, and when to use which.
config assumes your app needs structured, hierarchical configuration that varies by deployment environment (development, test, staging, production). It loads .json, .yaml, or .js files from a dedicated config/ directory and merges them based on NODE_ENV.
// config/default.json
{
"db": {
"host": "localhost",
"port": 5432
},
"apiTimeout": 5000
}
// config/production.json
{
"db": {
"host": "prod-db.example.com"
}
}
// In code
const config = require('config');
console.log(config.get('db.host')); // 'prod-db.example.com' in production
dotenv, by contrast, embraces flat key-value pairs stored in a .env file. It simply parses the file and injects variables into process.env. There’s no hierarchy, merging logic, or built-in support for multiple environments beyond what you layer yourself.
# .env
DB_HOST=localhost
DB_PORT=5432
API_TIMEOUT=5000
// In code
require('dotenv').config();
console.log(process.env.DB_HOST); // 'localhost'
This difference is critical: config gives you structure out of the box; dotenv gives you simplicity and leaves structure up to you.
Sometimes you need to compose values — for example, building a URL from a host and port. Neither config nor basic dotenv supports this natively.
dotenv-expand solves this by enabling variable interpolation within .env files, using syntax familiar from shell scripts.
# .env
HOST=localhost
PORT=3000
BASE_URL=http://${HOST}:${PORT}
// In code
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenv.config();
dotenvExpand.expand(myEnv);
console.log(process.env.BASE_URL); // 'http://localhost:3000'
Note that dotenv-expand is not a standalone package — it’s designed to wrap the result of dotenv.config(). You always use it with dotenv.
config doesn’t support this kind of string interpolation directly. If you need computed values, you’d typically define them in a .js config file:
// config/default.js
const host = process.env.HOST || 'localhost';
const port = process.env.PORT || 3000;
module.exports = {
baseUrl: `http://${host}:${port}`
};
So if your configuration logic lives in .env files and requires composition, dotenv + dotenv-expand is your path. If you’re already using JavaScript-based config files, config handles this naturally.
A missing environment variable can cause subtle bugs or crashes in production. Basic dotenv offers no safeguards — if a variable is missing, process.env.MY_VAR is just undefined.
dotenv-safe addresses this by allowing you to declare required variables via an .env.example (or custom) file. If any required variable is missing from your actual .env, it throws an error at startup.
# .env.example (defines required vars)
DB_HOST=
API_KEY=
# .env (must include all keys from .env.example)
DB_HOST=prod.example.com
API_KEY=secret123
// In code
require('dotenv-safe').config();
// Throws if .env is missing DB_HOST or API_KEY
config has a similar safety net via its util.validateConfig() method (though less commonly used), but more often, developers rely on runtime checks like config.has('required.key') or schema validation libraries (e.g., Joi) layered on top.
Neither dotenv nor dotenv-expand includes validation — that’s why dotenv-safe exists as a drop-in replacement for dotenv when safety matters.
config expects a dedicated config/ directory with multiple files. This works well in traditional server deployments where you can manage filesystem layout, but it’s awkward in containerized or serverless environments where config is often injected via environment variables at runtime.
dotenv and its variants assume a single .env file (plus optional .env.local, etc.). This aligns better with 12-factor app principles, where config is strictly separated from code and passed via environment variables. Many CI/CD systems and platforms (like Vercel, Netlify, Heroku) expect this model.
If your team deploys via Docker or Kubernetes and injects secrets as env vars, dotenv may feel redundant — you might skip it entirely and read process.env directly. But during local development, .env files are invaluable for replicating production-like settings without hardcoding secrets.
It’s common to combine these tools rather than pick just one:
dotenv (or dotenv-safe) to load .env into process.env during local development.config to build structured app settings from those environment variables.Example:
// Load .env safely
require('dotenv-safe').config({
allowEmptyValues: true,
example: '.env.example'
});
// Then use config to organize settings
const appConfig = require('config');
// config/default.json can reference process.env values
And if you need variable expansion in .env, just add dotenv-expand:
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenv.config();
dotenvExpand.expand(myEnv);
In fact, dotenv-safe and dotenv-expand are complementary, not competing — you can even use both:
const dotenvSafe = require('dotenv-safe');
const dotenvExpand = require('dotenv-expand');
const myEnv = dotenvSafe.config();
dotenvExpand.expand(myEnv);
This gives you validation and interpolation.
All four packages are Node.js-only. They rely on filesystem access (fs.readFileSync) and won’t work in browser environments. For frontend apps, you typically inline environment variables at build time (e.g., via Webpack’s DefinePlugin or Vite’s import.meta.env).
Also, none provide type safety out of the box. You’ll still need TypeScript interfaces or runtime validation (e.g., Zod, Joi) to ensure config shapes are correct.
| Package | Primary Use Case | Hierarchical? | Validates Required Vars? | Supports Interpolation? | Standalone? |
|---|---|---|---|---|---|
config | Structured, multi-env config files | ✅ | ❌ (manual) | ❌ | ✅ |
dotenv | Simple .env → process.env | ❌ | ❌ | ❌ | ✅ |
dotenv-expand | Add variable interpolation to dotenv | ❌ | ❌ | ✅ | ❌ (needs dotenv) |
dotenv-safe | Safe dotenv with required var checking | ❌ | ✅ | ❌ | ✅ |
config. It scales well as your environment matrix grows.dotenv-safe + dotenv-expand during local dev, but read process.env directly in production.dotenv in production code without validation — missing vars cause hard-to-debug failures.dotenv-safe to load env vars and config to structure them.The right choice isn’t about which package is “best” — it’s about matching the tool to your team’s deployment strategy, config complexity, and tolerance for runtime errors.
Choose dotenv when you want a minimal, straightforward way to load environment variables from a .env file into process.env for local development. It’s perfect for simple applications or when you plan to manage configuration externally in production (e.g., via platform-provided env vars). Avoid it in production-critical paths without additional validation, as it provides no safeguards for missing or malformed variables.
Choose dotenv-expand when you’re already using dotenv and need to compose environment variables within your .env file using shell-like interpolation (e.g., DATABASE_URL=postgresql://${DB_HOST}:${DB_PORT}/mydb). Remember that it’s not a standalone package — it must be used alongside dotenv or dotenv-safe. Don’t use it if your configuration logic is better handled in JavaScript or if you don’t need variable composition.
Choose config when you need a structured, hierarchical configuration system that supports multiple environments (development, staging, production) through dedicated config files. It’s ideal for complex applications where settings are organized in nested objects and you want automatic merging based on NODE_ENV. However, avoid it if your deployment model relies solely on injected environment variables without filesystem access, such as in some serverless or containerized setups.
Choose dotenv-safe when you need the simplicity of dotenv but require runtime validation to ensure all necessary environment variables are defined. It’s especially valuable in team environments or CI pipelines where missing configuration should fail fast rather than cause subtle bugs. Use it as a drop-in replacement for dotenv when safety is a priority, and consider pairing it with dotenv-expand if you also need variable interpolation.
Special thanks to our sponsors
Dotenv is a zero-dependency module that loads environment variables from a .env file into process.env. Storing configuration in the environment separate from code is based on The Twelve-Factor App methodology.
npm install dotenv --save
You can also use an npm-compatible package manager like yarn, bun or pnpm:
yarn add dotenv
bun add dotenv
pnpm add dotenv
Create a .env file in the root of your project (if using a monorepo structure like apps/backend/app.js, put it in the root of the folder where your app.js process runs):
S3_BUCKET="YOURS3BUCKET"
SECRET_KEY="YOURSECRETKEYGOESHERE"
As early as possible in your application, import and configure dotenv:
require('dotenv').config()
console.log(process.env) // remove this after you've confirmed it is working
import 'dotenv/config'
ES6 import if you need to set config options:
import dotenv from 'dotenv'
dotenv.config({ path: '/custom/path/to/.env' })
That's it. process.env now has the keys and values you defined in your .env file:
require('dotenv').config()
// or import 'dotenv/config' if you're using ES6
...
s3.getBucketCors({Bucket: process.env.S3_BUCKET}, function(err, data) {})
If you need multiline variables, for example private keys, those are now supported (>= v15.0.0) with line breaks:
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
...
Kh9NV...
...
-----END RSA PRIVATE KEY-----"
Alternatively, you can double quote strings and use the \n character:
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nKh9NV...\n-----END RSA PRIVATE KEY-----\n"
Comments may be added to your file on their own line or inline:
# This is a comment
SECRET_KEY=YOURSECRETKEYGOESHERE # comment
SECRET_HASH="something-with-a-#-hash"
Comments begin where a # exists, so if your value contains a # please wrap it in quotes. This is a breaking change from >= v15.0.0 and on.
The engine which parses the contents of your file containing environment variables is available to use. It accepts a String or Buffer and will return an Object with the parsed keys and values.
const dotenv = require('dotenv')
const buf = Buffer.from('BASIC=basic')
const config = dotenv.parse(buf) // will return an object
console.log(typeof config, config) // object { BASIC : 'basic' }
Note: Consider using
dotenvxinstead of preloading. I am now doing (and recommending) so.It serves the same purpose (you do not need to require and load dotenv), adds better debugging, and works with ANY language, framework, or platform. – motdotla
You can use the --require (-r) command line option to preload dotenv. By doing this, you do not need to require and load dotenv in your application code.
$ node -r dotenv/config your_script.js
The configuration options below are supported as command line arguments in the format dotenv_config_<option>=value
$ node -r dotenv/config your_script.js dotenv_config_path=/custom/path/to/.env dotenv_config_debug=true
Additionally, you can use environment variables to set configuration options. Command line arguments will precede these.
$ DOTENV_CONFIG_<OPTION>=value node -r dotenv/config your_script.js
$ DOTENV_CONFIG_ENCODING=latin1 DOTENV_CONFIG_DEBUG=true node -r dotenv/config your_script.js dotenv_config_path=/custom/path/to/.env
Use dotenvx to use variable expansion.
Reference and expand variables already on your machine for use in your .env file.
# .env
USERNAME="username"
DATABASE_URL="postgres://${USERNAME}@localhost/my_database"
// index.js
console.log('DATABASE_URL', process.env.DATABASE_URL)
$ dotenvx run --debug -- node index.js
[dotenvx@0.14.1] injecting env (2) from .env
DATABASE_URL postgres://username@localhost/my_database
Use dotenvx to use command substitution.
Add the output of a command to one of your variables in your .env file.
# .env
DATABASE_URL="postgres://$(whoami)@localhost/my_database"
// index.js
console.log('DATABASE_URL', process.env.DATABASE_URL)
$ dotenvx run --debug -- node index.js
[dotenvx@0.14.1] injecting env (1) from .env
DATABASE_URL postgres://yourusername@localhost/my_database
You need to keep .env files in sync between machines, environments, or team members? Use dotenvx to encrypt your .env files and safely include them in source control. This still subscribes to the twelve-factor app rules by generating a decryption key separate from code.
Use dotenvx to generate .env.ci, .env.production files, and more.
You need to deploy your secrets in a cloud-agnostic manner? Use dotenvx to generate a private decryption key that is set on your production server.
Use dotenvx
Run any environment locally. Create a .env.ENVIRONMENT file and use --env-file to load it. It's straightforward, yet flexible.
$ echo "HELLO=production" > .env.production
$ echo "console.log('Hello ' + process.env.HELLO)" > index.js
$ dotenvx run --env-file=.env.production -- node index.js
Hello production
> ^^
or with multiple .env files
$ echo "HELLO=local" > .env.local
$ echo "HELLO=World" > .env
$ echo "console.log('Hello ' + process.env.HELLO)" > index.js
$ dotenvx run --env-file=.env.local --env-file=.env -- node index.js
Hello local
Use dotenvx.
Add encryption to your .env files with a single command. Pass the --encrypt flag.
$ dotenvx set HELLO Production --encrypt -f .env.production
$ echo "console.log('Hello ' + process.env.HELLO)" > index.js
$ DOTENV_PRIVATE_KEY_PRODUCTION="<.env.production private key>" dotenvx run -- node index.js
[dotenvx] injecting env (2) from .env.production
Hello Production
See examples of using dotenv with various frameworks, languages, and configurations.
Dotenv exposes four functions:
configparsepopulateconfig will read your .env file, parse the contents, assign it to
process.env,
and return an Object with a parsed key containing the loaded content or an error key if it failed.
const result = dotenv.config()
if (result.error) {
throw result.error
}
console.log(result.parsed)
You can additionally, pass options to config.
Default: path.resolve(process.cwd(), '.env')
Specify a custom path if your file containing environment variables is located elsewhere.
require('dotenv').config({ path: '/custom/path/to/.env' })
By default, config will look for a file called .env in the current working directory.
Pass in multiple files as an array, and they will be parsed in order and combined with process.env (or option.processEnv, if set). The first value set for a variable will win, unless the options.override flag is set, in which case the last value set will win. If a value already exists in process.env and the options.override flag is NOT set, no changes will be made to that value.
require('dotenv').config({ path: ['.env.local', '.env'] })
Default: false
Suppress runtime logging message.
// index.js
require('dotenv').config({ quiet: false }) // change to true to suppress
console.log(`Hello ${process.env.HELLO}`)
# .env
.env
$ node index.js
[dotenv@17.0.0] injecting env (1) from .env
Hello World
Default: utf8
Specify the encoding of your file containing environment variables.
require('dotenv').config({ encoding: 'latin1' })
Default: false
Turn on logging to help debug why certain keys or values are not being set as you expect.
require('dotenv').config({ debug: process.env.DEBUG })
Default: false
Override any environment variables that have already been set on your machine with values from your .env file(s). If multiple files have been provided in option.path the override will also be used as each file is combined with the next. Without override being set, the first value wins. With override set the last value wins.
require('dotenv').config({ override: true })
Default: process.env
Specify an object to write your environment variables to. Defaults to process.env environment variables.
const myObject = {}
require('dotenv').config({ processEnv: myObject })
console.log(myObject) // values from .env
console.log(process.env) // this was not changed or written to
The engine which parses the contents of your file containing environment variables is available to use. It accepts a String or Buffer and will return an Object with the parsed keys and values.
const dotenv = require('dotenv')
const buf = Buffer.from('BASIC=basic')
const config = dotenv.parse(buf) // will return an object
console.log(typeof config, config) // object { BASIC : 'basic' }
Default: false
Turn on logging to help debug why certain keys or values are not being set as you expect.
const dotenv = require('dotenv')
const buf = Buffer.from('hello world')
const opt = { debug: true }
const config = dotenv.parse(buf, opt)
// expect a debug message because the buffer is not in KEY=VAL form
The engine which populates the contents of your .env file to process.env is available for use. It accepts a target, a source, and options. This is useful for power users who want to supply their own objects.
For example, customizing the source:
const dotenv = require('dotenv')
const parsed = { HELLO: 'world' }
dotenv.populate(process.env, parsed)
console.log(process.env.HELLO) // world
For example, customizing the source AND target:
const dotenv = require('dotenv')
const parsed = { HELLO: 'universe' }
const target = { HELLO: 'world' } // empty object
dotenv.populate(target, parsed, { override: true, debug: true })
console.log(target) // { HELLO: 'universe' }
Default: false
Turn on logging to help debug why certain keys or values are not being populated as you expect.
Default: false
Override any environment variables that have already been set.
.env file not loading my environment variables successfully?Most likely your .env file is not in the correct place. See this stack overflow.
Turn on debug mode and try again..
require('dotenv').config({ debug: true })
You will receive a helpful error outputted to your console.
.env file?No. We strongly recommend against committing your .env file to version
control. It should only include environment-specific values such as database
passwords or API keys. Your production database should have a different
password than your development database.
.env files?We recommend creating one .env file per environment. Use .env for local/development, .env.production for production and so on. This still follows the twelve factor principles as each is attributed individually to its own environment. Avoid custom set ups that work in inheritance somehow (.env.production inherits values form .env for example). It is better to duplicate values if necessary across each .env.environment file.
In a twelve-factor app, env vars are granular controls, each fully orthogonal to other env vars. They are never grouped together as “environments”, but instead are independently managed for each deploy. This is a model that scales up smoothly as the app naturally expands into more deploys over its lifetime.
The parsing engine currently supports the following rules:
BASIC=basic becomes {BASIC: 'basic'}# are treated as comments# marks the beginning of a comment (unless when the value is wrapped in quotes)EMPTY= becomes {EMPTY: ''})JSON={"foo": "bar"} becomes {JSON:"{\"foo\": \"bar\"}")trim) (FOO= some value becomes {FOO: 'some value'})SINGLE_QUOTE='quoted' becomes {SINGLE_QUOTE: "quoted"})FOO=" some value " becomes {FOO: ' some value '})MULTILINE="new\nline" becomes{MULTILINE: 'new
line'}
BACKTICK_KEY=`This has 'single' and "double" quotes inside of it.`)By default, we will never modify any environment variables that have already been set. In particular, if there is a variable in your .env file which collides with one that already exists in your environment, then that variable will be skipped.
If instead, you want to override process.env use the override option.
require('dotenv').config({ override: true })
Your React code is run in Webpack, where the fs module or even the process global itself are not accessible out-of-the-box. process.env can only be injected through Webpack configuration.
If you are using react-scripts, which is distributed through create-react-app, it has dotenv built in but with a quirk. Preface your environment variables with REACT_APP_. See this stack overflow for more details.
If you are using other frameworks (e.g. Next.js, Gatsby...), you need to consult their documentation for how to inject environment variables into the client.
Yes! dotenv.config() returns an object representing the parsed .env file. This gives you everything you need to continue setting values on process.env. For example:
const dotenv = require('dotenv')
const variableExpansion = require('dotenv-expand')
const myEnv = dotenv.config()
variableExpansion(myEnv)
import?Simply..
// index.mjs (ESM)
import 'dotenv/config' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import
import express from 'express'
A little background..
When you run a module containing an
importdeclaration, the modules it imports are loaded first, then each module body is executed in a depth-first traversal of the dependency graph, avoiding cycles by skipping anything already executed.
What does this mean in plain language? It means you would think the following would work but it won't.
errorReporter.mjs:
class Client {
constructor (apiKey) {
console.log('apiKey', apiKey)
this.apiKey = apiKey
}
}
export default new Client(process.env.API_KEY)
index.mjs:
// Note: this is INCORRECT and will not work
import * as dotenv from 'dotenv'
dotenv.config()
import errorReporter from './errorReporter.mjs' // process.env.API_KEY will be blank!
process.env.API_KEY will be blank.
Instead, index.mjs should be written as..
import 'dotenv/config'
import errorReporter from './errorReporter.mjs'
Does that make sense? It's a bit unintuitive, but it is how importing of ES6 modules work. Here is a working example of this pitfall.
There are two alternatives to this approach:
dotenvx run -- node index.js (Note: you do not need to import dotenv with this approach)config first as outlined in this comment on #133Module not found: Error: Can't resolve 'crypto|os|path'?You are using dotenv on the front-end and have not included a polyfill. Webpack < 5 used to include these for you. Do the following:
npm install node-polyfill-webpack-plugin
Configure your webpack.config.js to something like the following.
require('dotenv').config()
const path = require('path');
const webpack = require('webpack')
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
module.exports = {
mode: 'development',
entry: './src/index.ts',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new NodePolyfillPlugin(),
new webpack.DefinePlugin({
'process.env': {
HELLO: JSON.stringify(process.env.HELLO)
}
}),
]
};
Alternatively, just use dotenv-webpack which does this and more behind the scenes for you.
Try dotenv-expand
Use dotenvx to unlock syncing encrypted .env files over git.
.env file to code?Remove it, remove git history and then install the git pre-commit hook to prevent this from ever happening again.
brew install dotenvx/brew/dotenvx
dotenvx precommit --install
.env file to a Docker build?Use the docker prebuild hook.
# Dockerfile
...
RUN curl -fsS https://dotenvx.sh/ | sh
...
RUN dotenvx prebuild
CMD ["dotenvx", "run", "--", "node", "index.js"]
See CONTRIBUTING.md
See CHANGELOG.md
These npm modules depend on it.
Projects that expand it often use the keyword "dotenv" on npm.