Environment Configuration with dotenv
A comprehensive guide to managing environment variables in Node.js with dotenv covering configuration validation, multiple environments, secrets management, and twelve-factor app principles.
Environment Configuration with dotenv
If you have ever deployed an application and watched it crash because a database connection string was hardcoded to localhost, you already understand why environment configuration matters. The dotenv package has become the de facto standard for managing environment variables in Node.js applications, and for good reason. It is simple, predictable, and it nudges you toward the right patterns. This guide covers everything from basic setup through production-grade configuration validation, multi-environment workflows, and the security considerations that separate hobby projects from professional software.
Prerequisites
Before diving in, you should have:
- Node.js v14 or later installed (v20+ recommended for the
--env-fileflag discussion) - Basic familiarity with Express.js and npm
- A terminal and text editor
- Understanding of what environment variables are at the OS level (
export,set,printenv)
Why Environment Variables Matter
The Twelve-Factor App methodology lays this out clearly: configuration that varies between deploys belongs in the environment, not in code. This is factor three, and it is non-negotiable for any application you plan to run in more than one place.
Here is why this matters in practice:
- Security. Database passwords and API keys do not belong in source control. Period. One leaked
.envfile committed to a public repository can compromise your entire infrastructure. - Portability. The same codebase should run in development, staging, and production without code changes. The environment tells the application how to behave.
- Team workflow. Different developers have different local configurations. Environment variables let each developer customize their setup without creating merge conflicts in config files.
- Deployment flexibility. Whether you deploy to DigitalOcean, AWS, Heroku, or a Docker container, every platform has first-class support for injecting environment variables. Hardcoded config ties you to one deployment strategy.
The alternative — config files checked into version control, or worse, hardcoded values — creates a maintenance nightmare that compounds with every environment you add.
dotenv Package Setup and Usage
Install dotenv as a regular dependency (not a dev dependency, since you need it at runtime in development):
npm install dotenv
Load it as early as possible in your application entry point:
// app.js
require("dotenv").config();
var express = require("express");
var app = express();
var port = process.env.PORT || 3000;
app.get("/", function (req, res) {
res.json({ status: "running", environment: process.env.NODE_ENV });
});
app.listen(port, function () {
console.log("Server listening on port " + port);
});
The require("dotenv").config() call reads a .env file from the current working directory, parses each line into key-value pairs, and assigns them to process.env. It does not override variables that already exist in the environment. This is an important detail — it means variables set by your deployment platform take precedence over values in your .env file.
.env File Format and Syntax Rules
Create a .env file in your project root:
# Database configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=myapp
DB_PASSWORD=local_dev_password
# API keys
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx
# Application settings
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
ENABLE_CACHE=false
# Multi-line values use quotes
WELCOME_MESSAGE="Hello and welcome\nto our application"
# Values with spaces need quotes
APP_NAME="My Express Application"
# Empty values are valid
ANALYTICS_ID=
Key syntax rules to remember:
- No spaces around
=—DB_HOST = localhostwill include the spaces in the key and value. - Comments start with
#and must be on their own line. - Quotes are optional for simple values. Use double quotes for values containing spaces, newlines (
\n), or special characters. - Single quotes are treated literally —
\ninside single quotes stays as the literal characters\n, not a newline. - Variable names are conventionally UPPER_SNAKE_CASE, though dotenv does not enforce this.
- No export keyword — unlike shell scripts,
.envfiles do not useexport. Some tools support it, but dotenv does not require it.
.env.example as Documentation
Every project that uses a .env file should include a .env.example file committed to version control. This file documents every variable your application expects, without including actual secret values:
# .env.example — Copy to .env and fill in values
# Database (required)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=
DB_PASSWORD=
# External APIs (required for full functionality)
OPENAI_API_KEY=
STRIPE_SECRET_KEY=
# Application (optional — defaults shown)
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
ENABLE_CACHE=false
This serves three purposes: it tells new developers exactly what they need to configure, it documents default values, and it acts as a checklist during deployment. When you add a new environment variable, update .env.example in the same commit. Your future self and your teammates will thank you.
Multiple Environment Files
As your application grows, you may need different default configurations for different environments. dotenv supports loading specific files by passing a path option:
// Load environment-specific .env file
var path = require("path");
var envFile = ".env." + (process.env.NODE_ENV || "development");
require("dotenv").config({
path: path.resolve(process.cwd(), envFile)
});
// Optionally also load the base .env as fallback
require("dotenv").config();
A typical project structure might include:
.env.development # Local dev defaults
.env.test # Test environment config
.env.production # Production defaults (no secrets!)
.env # Local overrides (gitignored)
.env.example # Template for documentation
Important: your .env.production file should never contain actual production secrets. It should only contain non-sensitive production defaults like LOG_LEVEL=error or ENABLE_CACHE=true. Real secrets come from your deployment platform's environment variable management.
The loading order matters. Since dotenv does not override existing variables, load the most specific file first and the base file second. Variables from the first file win.
dotenv-expand for Variable Interpolation
Sometimes you need to compose variables from other variables. The dotenv-expand package handles this:
npm install dotenv-expand
# .env with variable references
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=secret123
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
var dotenv = require("dotenv");
var dotenvExpand = require("dotenv-expand");
var env = dotenv.config();
dotenvExpand.expand(env);
console.log(process.env.DATABASE_URL);
// postgresql://admin:secret123@localhost:5432/myapp
This is particularly useful when you have a service that requires a connection URL but you want to keep the individual components configurable. ORMs like Sequelize and Knex often want a full connection string, while your monitoring dashboard might need host and port separately.
Configuration Validation at Startup
Loading environment variables is only half the problem. The other half is making sure you actually have what you need before your application starts serving requests. A missing API key that manifests as a cryptic error 20 minutes into production is far worse than a clear failure at startup.
Here is a manual validation approach that works without additional dependencies:
function validateEnv(requirements) {
var missing = [];
var invalid = [];
Object.keys(requirements).forEach(function (key) {
var rule = requirements[key];
var value = process.env[key];
if (rule.required && !value) {
missing.push(key);
return;
}
if (value && rule.pattern && !rule.pattern.test(value)) {
invalid.push(key + " (expected format: " + rule.format + ")");
}
});
if (missing.length > 0 || invalid.length > 0) {
console.error("Environment configuration errors:");
if (missing.length > 0) {
console.error(" Missing required variables: " + missing.join(", "));
}
if (invalid.length > 0) {
console.error(" Invalid variables: " + invalid.join(", "));
}
process.exit(1);
}
}
validateEnv({
DB_HOST: { required: true },
DB_PORT: { required: true, pattern: /^\d+$/, format: "numeric" },
NODE_ENV: { required: false },
OPENAI_API_KEY: { required: true, pattern: /^sk-/, format: "starts with sk-" }
});
If you prefer a more robust approach, Joi provides excellent schema validation:
npm install joi
var Joi = require("joi");
var envSchema = Joi.object({
NODE_ENV: Joi.string()
.valid("development", "production", "test")
.default("development"),
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().required(),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
OPENAI_API_KEY: Joi.string().pattern(/^sk-/).required(),
ENABLE_CACHE: Joi.boolean().default(false),
LOG_LEVEL: Joi.string()
.valid("error", "warn", "info", "debug")
.default("info")
}).unknown(true);
var validation = envSchema.validate(process.env, { abortEarly: false });
if (validation.error) {
console.error("Config validation error:", validation.error.message);
process.exit(1);
}
The unknown(true) option tells Joi to ignore extra environment variables (there will be hundreds of them from the OS). The abortEarly: false option collects all errors instead of stopping at the first one.
Type Coercion for Booleans and Numbers
Every value in process.env is a string. This catches people constantly:
// DANGER: This is always truthy, even when ENABLE_CACHE=false
if (process.env.ENABLE_CACHE) {
// This runs even when the value is the string "false"
}
You need explicit type coercion:
function parseBoolean(value, defaultValue) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
return value === "true" || value === "1" || value === "yes";
}
function parseInteger(value, defaultValue) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
var parsed = parseInt(value, 10);
if (isNaN(parsed)) {
throw new Error("Expected integer but got: " + value);
}
return parsed;
}
var enableCache = parseBoolean(process.env.ENABLE_CACHE, false);
var port = parseInteger(process.env.PORT, 3000);
var maxRetries = parseInteger(process.env.MAX_RETRIES, 3);
This is such a common need that it should be built into your configuration module rather than scattered throughout your codebase.
Organizing Config into a Config Module
Instead of accessing process.env directly throughout your application, centralize configuration into a single module. This gives you one place for defaults, type coercion, validation, and documentation:
// config.js
require("dotenv").config();
var Joi = require("joi");
var envSchema = Joi.object({
NODE_ENV: Joi.string()
.valid("development", "production", "test")
.default("development"),
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().required(),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
OPENAI_API_KEY: Joi.string().required(),
STRIPE_SECRET_KEY: Joi.string().when("NODE_ENV", {
is: "production",
then: Joi.required(),
otherwise: Joi.optional().default("")
}),
LOG_LEVEL: Joi.string()
.valid("error", "warn", "info", "debug")
.default("info"),
ENABLE_CACHE: Joi.boolean().truthy("1", "yes").falsy("0", "no").default(false),
CACHE_TTL_SECONDS: Joi.number().default(300),
CORS_ORIGIN: Joi.string().default("*"),
RATE_LIMIT_WINDOW_MS: Joi.number().default(900000),
RATE_LIMIT_MAX: Joi.number().default(100)
}).unknown(true);
var result = envSchema.validate(process.env, {
abortEarly: false,
convert: true
});
if (result.error) {
var messages = result.error.details.map(function (detail) {
return " - " + detail.message;
});
console.error("Configuration validation failed:\n" + messages.join("\n"));
process.exit(1);
}
var env = result.value;
var config = {
env: env.NODE_ENV,
isProduction: env.NODE_ENV === "production",
isDevelopment: env.NODE_ENV === "development",
isTest: env.NODE_ENV === "test",
server: {
port: env.PORT,
corsOrigin: env.CORS_ORIGIN
},
database: {
host: env.DB_HOST,
port: env.DB_PORT,
name: env.DB_NAME,
user: env.DB_USER,
password: env.DB_PASSWORD,
url: "postgresql://" + env.DB_USER + ":" + env.DB_PASSWORD +
"@" + env.DB_HOST + ":" + env.DB_PORT + "/" + env.DB_NAME
},
api: {
openaiKey: env.OPENAI_API_KEY,
stripeKey: env.STRIPE_SECRET_KEY
},
cache: {
enabled: env.ENABLE_CACHE,
ttlSeconds: env.CACHE_TTL_SECONDS
},
rateLimit: {
windowMs: env.RATE_LIMIT_WINDOW_MS,
max: env.RATE_LIMIT_MAX
},
logging: {
level: env.LOG_LEVEL
}
};
module.exports = config;
Now every other file in your application imports config instead of reading process.env:
// routes/api.js
var config = require("../config");
app.get("/api/data", function (req, res) {
if (config.cache.enabled) {
// check cache first
}
// ...
});
This pattern has several advantages. Autocomplete works. You can write unit tests against the config object. Variable names are consistent. And if you ever need to switch from dotenv to something else, you change one file.
Complete Working Example
Here is a complete Express.js application demonstrating these patterns together:
// config.js
require("dotenv").config();
function requiredVar(name) {
var value = process.env[name];
if (!value) {
console.error("FATAL: Missing required environment variable: " + name);
process.exit(1);
}
return value;
}
function optionalVar(name, defaultValue) {
var value = process.env[name];
if (value === undefined || value === "") {
return defaultValue;
}
return value;
}
function booleanVar(name, defaultValue) {
var value = process.env[name];
if (value === undefined || value === "") {
return defaultValue;
}
return value === "true" || value === "1" || value === "yes";
}
function intVar(name, defaultValue) {
var value = process.env[name];
if (value === undefined || value === "") {
return defaultValue;
}
var parsed = parseInt(value, 10);
if (isNaN(parsed)) {
console.error("FATAL: " + name + " must be a number, got: " + value);
process.exit(1);
}
return parsed;
}
var config = {
env: optionalVar("NODE_ENV", "development"),
port: intVar("PORT", 3000),
logLevel: optionalVar("LOG_LEVEL", "info"),
db: {
host: requiredVar("DB_HOST"),
port: intVar("DB_PORT", 5432),
name: requiredVar("DB_NAME"),
user: requiredVar("DB_USER"),
password: requiredVar("DB_PASSWORD")
},
api: {
openaiKey: requiredVar("OPENAI_API_KEY")
},
features: {
enableCache: booleanVar("ENABLE_CACHE", false),
enableAnalytics: booleanVar("ENABLE_ANALYTICS", false),
maintenanceMode: booleanVar("MAINTENANCE_MODE", false)
}
};
config.isProduction = config.env === "production";
config.isDevelopment = config.env === "development";
config.db.url = "postgresql://" + config.db.user + ":" +
config.db.password + "@" + config.db.host + ":" +
config.db.port + "/" + config.db.name;
console.log("Config loaded: env=" + config.env +
", port=" + config.port +
", db=" + config.db.host + ":" + config.db.port + "/" + config.db.name +
", cache=" + config.features.enableCache);
module.exports = config;
// app.js
var config = require("./config");
var express = require("express");
var app = express();
app.use(express.json());
// Feature flag middleware
app.use(function (req, res, next) {
if (config.features.maintenanceMode && req.path !== "/health") {
return res.status(503).json({ error: "Service under maintenance" });
}
next();
});
// Health check endpoint
app.get("/health", function (req, res) {
res.json({
status: "ok",
environment: config.env,
features: config.features
});
});
// Route that uses config
app.get("/api/summary", function (req, res) {
// The OpenAI key is available via config, not process.env
res.json({
message: "API is running",
cacheEnabled: config.features.enableCache,
dbHost: config.db.host
});
});
app.listen(config.port, function () {
console.log("Server running on port " + config.port);
});
If you start this application without a .env file and without the required variables set, it will exit immediately with a clear error message telling you exactly what is missing.
Secrets Management: What NOT to Put in .env
The .env file is a development convenience. It is not a secrets vault. Here is the distinction:
Acceptable in .env for local development:
- Database credentials for your local dev database
- Test API keys (Stripe test mode, sandbox accounts)
- Feature flags
- Port numbers and host configurations
Never acceptable in .env in production:
- Production database passwords
- Live payment processing keys
- Private signing keys or certificates
- OAuth client secrets for production
In production, secrets should come from your platform's secrets management:
- DigitalOcean App Platform: App-level environment variables in the dashboard
- AWS: Secrets Manager or Parameter Store
- Docker: Docker secrets or environment variables passed at runtime
- Kubernetes: Kubernetes Secrets
- Heroku: Config vars via
heroku config:set
The .env file should never exist on a production server.
.gitignore and .env Security
Add .env to your .gitignore immediately. Do this before creating the .env file. If the .env file has already been committed, removing it from .gitignore alone is not enough — the file exists in git history. You need to purge it:
# Add to .gitignore
echo ".env" >> .gitignore
# Remove from tracking (keeps the local file)
git rm --cached .env
# Commit the removal
git commit -m "Remove .env from version control"
Files that should be committed: .env.example, .env.test (if it contains no secrets), .env.development (if it contains no secrets).
Files that should not be committed: .env, .env.local, .env.production.local, any file containing actual API keys or passwords.
Consider using a pre-commit hook with a tool like git-secrets to scan for accidentally committed credentials.
Docker and .env Files
Docker has native support for .env files:
# docker-compose.yml reads .env automatically
docker-compose up
# Or specify explicitly
docker run --env-file .env myapp
# Or pass individual variables
docker run -e DB_HOST=db -e DB_PORT=5432 myapp
In a docker-compose.yml:
version: "3.8"
services:
app:
build: .
env_file:
- .env
ports:
- "${PORT:-3000}:3000"
depends_on:
- db
db:
image: postgres:15
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
One common mistake: copying the .env file into your Docker image via COPY. Add .env to your .dockerignore file to prevent this. The environment should be injected at runtime, not baked into the image.
CI/CD Environment Variable Injection
Every CI/CD platform provides a way to set environment variables securely:
GitHub Actions:
env:
NODE_ENV: test
DB_HOST: localhost
steps:
- name: Run tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: npm test
GitLab CI:
variables:
NODE_ENV: test
DB_HOST: localhost
test:
script:
- npm test
The pattern is consistent across platforms: non-sensitive values go in the pipeline config file, and secrets are stored in the platform's encrypted secrets storage and referenced by name.
In your test environment, you might create the .env file dynamically:
# In your CI script
echo "DB_HOST=localhost" > .env
echo "DB_PORT=5432" >> .env
echo "DB_NAME=test_db" >> .env
echo "NODE_ENV=test" >> .env
npm test
Debugging Missing Variables
When a variable is not loading as expected, work through this checklist:
Check the file location. dotenv loads from
process.cwd(), not from the file whererequire("dotenv")is called. If you start your app from a different directory, the.envfile will not be found.Check for the override behavior. dotenv does not override existing environment variables. If
DB_HOSTis already set in your shell, the.envvalue is ignored. Userequire("dotenv").config({ override: true })if you need.envto take precedence.Log the result of config():
var result = require("dotenv").config();
if (result.error) {
console.error("dotenv error:", result.error);
} else {
console.log("dotenv loaded:", Object.keys(result.parsed));
}
Check for BOM characters. If your
.envfile was created in certain Windows editors, it might have a UTF-8 BOM that corrupts the first variable name. Open the file in a hex editor or recreate it.Check load order. Make sure
require("dotenv").config()runs before any module that readsprocess.env. If yourequire("./database")before loading dotenv, the database module will see undefined values.
dotenv Alternatives: Node.js 20+ --env-file
Starting with Node.js v20.6.0, there is a built-in alternative:
node --env-file=.env app.js
This loads the .env file before your application code runs, eliminating the need for the dotenv package entirely. The format is the same as dotenv's .env files.
# You can specify multiple files
node --env-file=.env --env-file=.env.local app.js
The advantages are clear: no dependency, no require call to forget, and the variables are available before any module loads. The downside is that you lose programmatic control — no conditional loading, no validation at the point of loading, and no variable expansion without additional tooling.
My recommendation: if you are starting a new project on Node.js 20+, try --env-file first. Add the dotenv package later if you need features like variable expansion or programmatic configuration. For existing projects, there is no urgency to migrate away from dotenv. It works, it is stable, and switching gains you very little.
Common Issues and Troubleshooting
1. Variables are undefined despite being in .env
The most common cause is load order. Ensure require("dotenv").config() is the very first line in your entry point, before any other require calls that might access process.env.
2. Boolean values are always truthy
Remember that process.env.ENABLE_FEATURE is the string "false", which is truthy in JavaScript. Always use explicit string comparison or a type coercion helper function.
3. .env file not found in Docker containers
The working directory inside a container may differ from your local setup. Use an absolute path: require("dotenv").config({ path: "/app/.env" }) or rely on Docker's --env-file flag instead.
4. Variables from the OS override .env values
This is by design. dotenv does not overwrite existing environment variables. If you need the .env file to take precedence during development, use the override: true option. But be aware that this can mask real environment variables in deployment.
5. Whitespace in values causing connection failures
An extra space in DB_PASSWORD=mysecret (note the trailing space) can cause authentication failures that are extremely difficult to diagnose. Wrap values in double quotes if you are uncertain, and trim values in your config module as a defensive measure.
Best Practices
Load dotenv once, at the entry point. Never call
require("dotenv").config()in library files or modules. Load it inapp.jsorserver.jsand let the config module handle distribution.Validate configuration at startup. Fail fast with clear error messages. A crash at startup is infinitely better than a crash at 3 AM when a code path finally tries to use a missing API key.
Never commit .env files. Always commit
.env.example. Set up your.gitignorebefore creating your first.envfile.Use a centralized config module. Do not scatter
process.env.SOME_VARthroughout your codebase. Centralize it so you have one place to manage defaults, types, and validation.Document every variable. Your
.env.exampleshould include comments explaining what each variable does, whether it is required, and what the default value is.Keep .env files minimal. Only include variables that differ from defaults. If your config module defaults
PORTto3000, do not putPORT=3000in your.envfile. This keeps the file focused on what actually needs to be configured.Separate secrets from configuration. Non-sensitive configuration like
LOG_LEVELandPORTcan live in committed environment files. Secrets like API keys and database passwords should only exist in.env(local) or your platform's secrets manager (production).Use consistent naming conventions. Stick with
UPPER_SNAKE_CASE. Prefix related variables with a common namespace:DB_HOST,DB_PORT,DB_NAMErather thanHOST,PORT,DATABASE.
References
- dotenv on npm — Official package documentation
- The Twelve-Factor App: Config — Factor III on configuration
- dotenv-expand — Variable interpolation for dotenv
- Node.js --env-file documentation — Built-in .env support in Node 20+
- Joi validation library — Schema validation for JavaScript
- OWASP Secrets Management — Security best practices for secrets