Nodejs

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-file flag 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:

  1. Security. Database passwords and API keys do not belong in source control. Period. One leaked .env file committed to a public repository can compromise your entire infrastructure.
  2. Portability. The same codebase should run in development, staging, and production without code changes. The environment tells the application how to behave.
  3. Team workflow. Different developers have different local configurations. Environment variables let each developer customize their setup without creating merge conflicts in config files.
  4. 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 = localhost will 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 — \n inside 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, .env files do not use export. 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:

  1. Check the file location. dotenv loads from process.cwd(), not from the file where require("dotenv") is called. If you start your app from a different directory, the .env file will not be found.

  2. Check for the override behavior. dotenv does not override existing environment variables. If DB_HOST is already set in your shell, the .env value is ignored. Use require("dotenv").config({ override: true }) if you need .env to take precedence.

  3. 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));
}
  1. Check for BOM characters. If your .env file 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.

  2. Check load order. Make sure require("dotenv").config() runs before any module that reads process.env. If you require("./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

  1. Load dotenv once, at the entry point. Never call require("dotenv").config() in library files or modules. Load it in app.js or server.js and let the config module handle distribution.

  2. 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.

  3. Never commit .env files. Always commit .env.example. Set up your .gitignore before creating your first .env file.

  4. Use a centralized config module. Do not scatter process.env.SOME_VAR throughout your codebase. Centralize it so you have one place to manage defaults, types, and validation.

  5. Document every variable. Your .env.example should include comments explaining what each variable does, whether it is required, and what the default value is.

  6. Keep .env files minimal. Only include variables that differ from defaults. If your config module defaults PORT to 3000, do not put PORT=3000 in your .env file. This keeps the file focused on what actually needs to be configured.

  7. Separate secrets from configuration. Non-sensitive configuration like LOG_LEVEL and PORT can 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).

  8. Use consistent naming conventions. Stick with UPPER_SNAKE_CASE. Prefix related variables with a common namespace: DB_HOST, DB_PORT, DB_NAME rather than HOST, PORT, DATABASE.

References

Powered by Contentful