Node.js Environment Variables and Secrets: dotenv and Best Practices

Environment variables in Node.js with dotenv 6: a .env.example contract, a validated Joi config module, fail-fast startup and safe secrets.

Executive Summary: I treat environment variables as an API between my code and the platform it runs on. Here I show how I load them with dotenv 6, document them in a .env.example file, validate every value at startup with Joi, and keep secrets out of code, Docker images and logs.
Environment variables concept: a retro safe for secrets, a panel of switches and dials, and a dotted line to a terminal window

Sooner or later, every Node.js app needs a database URL, a port and a secret key. Hard-coding them works on your laptop and nowhere else. Here I write about how I handle environment variables in Node.js: loading them with dotenv, validating them at startup and keeping secrets out of the code.

The approach follows the Twelve-Factor App rule on config. Anything that changes between deploys belongs in the environment, not in the repository. The examples run on Node 10 LTS with dotenv 6 and Joi 14, and they work in any Node.js service, with or without Express.

What process.env actually gives you

Node exposes environment variables through process.env, a plain object built when the process starts. It looks simple, but three details catch people out.

First, every value is a string. PORT=4000 arrives as "4000", and DEBUG=false arrives as "false", which is truthy. So if (process.env.DEBUG) is true for any non-empty value.

Second, assignment converts to a string too. This surprised me the first time:

$ node -e "process.env.X = undefined; console.log(typeof process.env.X, JSON.stringify(process.env.X))"
string "undefined"

Third, a missing variable is simply undefined, with no warning. A typo such as DATABSE_URL fails silently until the first query runs, often minutes after the deploy. Because of these three problems, I never read process.env from application code directly. One module reads it once, checks it and exports clean values.

Loading environment variables from a .env file with dotenv

In production, the platform sets the environment variables: systemd, Docker, Kubernetes or your PaaS. On a development machine, typing ten variables before every start gets old fast. That is the job of dotenv. It reads a .env file and copies each line into process.env.

npm install dotenv@6
# .env  (never committed)
PORT=4000
DATABASE_URL=postgres://tasks:devpass@localhost:5432/tasks
JWT_SECRET=replace-me-with-at-least-32-random-characters

You can load it in two ways. The first is a call at the very top of the entry file, before anything reads the environment:

// src/server.js
require('dotenv').config();
const config = require('./config');

The second is a preload flag, which keeps the application code free of dotenv entirely:

node -r dotenv/config src/server.js

I prefer the preload in the dev script of package.json. That way, production never loads the file at all, even by accident. The npm scripts documentation covers how those scripts run.

One rule of dotenv matters a lot: it never overrides a variable that already exists. That is a feature. A value set by the shell or the platform always wins over the file:

$ PORT=5000 node -e "require('dotenv').config(); console.log(process.env.PORT)"
5000

The .env.example contract

The real .env file goes into .gitignore on day one. However, a new developer still needs to know which variables exist. So I commit a .env.example with every key and a safe placeholder:

# .env.example  (committed, no real values)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:password@localhost:5432/tasks
JWT_SECRET=
LOG_LEVEL=info

This file is the contract. When a pull request adds a new environment variable, it also adds a line here. Reviewers can then see a configuration change in the diff, instead of discovering it during a failed deploy.

One module that validates every environment variable

Here is the heart of the approach. A single config.js reads process.env once, validates it with Joi and exports a frozen object:

const Joi = require('joi');

const schema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
  PORT: Joi.number().port().default(3000),
  DATABASE_URL: Joi.string().uri({ scheme: ['postgres', 'postgresql'] }).required(),
  JWT_SECRET: Joi.string().min(32).required(),
  LOG_LEVEL: Joi.string().valid('debug', 'info', 'warn', 'error').default('info'),
}).unknown(true);

const { error, value } = Joi.validate(process.env, schema, { abortEarly: false });

if (error) {
  const problems = error.details.map((d) => `  - ${d.message}`).join('\n');
  console.error(`Invalid configuration:\n${problems}`);
  process.exit(1);
}

module.exports = Object.freeze({
  env: value.NODE_ENV,
  port: value.PORT,
  databaseUrl: value.DATABASE_URL,
  jwtSecret: value.JWT_SECRET,
  logLevel: value.LOG_LEVEL,
});

Each line of the schema does real work. Joi.number().port() converts the string and checks the range. The URI check rejects a URL with the wrong scheme. Next, the min(32) rule refuses a weak JWT secret, because a short HMAC secret can be guessed offline from one captured token.

The .unknown(true) call is required, because process.env also holds PATH, HOME and dozens of other system variables. Without it, Joi would reject all of them.

Finally, the rest of the app imports config, never process.env. Names become camelCase properties, types are real numbers, and Object.freeze stops code from changing settings at runtime.

Fail fast, with every problem and no secrets

The app validates environment variables before it opens a port or a database connection. If something is wrong, it exits at once. Here is the real output with a bad port, a missing URL and a short secret:

$ PORT=abc JWT_SECRET=short node src/server.js
Invalid configuration:
  - "PORT" must be a number
  - "DATABASE_URL" is required
  - "JWT_SECRET" length must be at least 32 characters long
$ echo $?
1

Three things make this output useful. Because of abortEarly: false, it lists every problem at once, so you fix them in one round. The messages name the key but never print the value, so a secret cannot end up in a CI log. Also, the exit code is 1, so a deploy pipeline or orchestrator sees the failure and keeps the old version running.

Compare that with the alternative. Without validation, the same app starts, passes a basic health check and then fails on the first real request. That failure arrives at 3 a.m. and looks like a database outage.

Secrets: keep them out of code, images and logs

Environment variables are a good transport for configuration. For secrets, they are acceptable but not perfect. A child process inherits them, a crash reporter may dump them, and docker inspect shows them to anyone with access to the host. So I follow a few extra rules for passwords, keys and tokens.

  • Never commit them. Not in .env, not in a config file and not in a test fixture. If one slips in, rotate it. Deleting the commit does not remove it from clones.
  • Never bake them into a Docker image. An ENV line in a Dockerfile ends up in the image layers, where anyone who pulls the image can read it. Pass secrets at run time instead.
  • Prefer secret files where the platform offers them. Docker secrets and Kubernetes secrets can mount a secret as a file. Then the config module reads the file path from an environment variable.
  • Use a manager for rotation. HashiCorp Vault or AWS Secrets Manager can rotate database passwords on a schedule. The app then fetches them at startup. The key management behind encryption at rest follows the same ideas.
  • Redact logs. Never log the whole config object, and never log process.env.

Reading a secret file takes only a few lines in the config module:

const fs = require('fs');

function readSecret(name) {
  const file = process.env[`${name}_FILE`];
  if (file) return fs.readFileSync(file, 'utf8').trim();
  return process.env[name];
}

process.env.JWT_SECRET = readSecret('JWT_SECRET');

This follows the _FILE convention that official Docker images such as postgres use. The same code works with a plain environment variable on a laptop and a mounted file in production.

Environment variables in tests and CI

Tests need environment variables too, and they should never touch the development database by accident. So I keep a second file, .env.test, with a separate database and a fixed dummy secret. The test script loads it explicitly:

"scripts": {
  "dev": "node -r dotenv/config src/server.js",
  "test": "NODE_ENV=test ENV_FILE=.env.test jest"
}

Then a Jest setup file calls require('dotenv').config({ path: process.env.ENV_FILE }) before any test imports the app. Because dotenv never overrides, a CI server can still inject its own values on top.

On the CI side, I store real credentials as encrypted environment variables in the CI tool, not in the repository. Travis CI, GitLab CI and Jenkins all support them. They also mask those values in build logs. Meanwhile, the config module behaves exactly as in production, so a missing CI variable fails the build in the first second.

One more habit helps here. I add a tiny test that loads config.js with an empty environment and checks that it exits with code 1. That test protects the fail-fast behaviour itself, which is easy to break during a refactor. I run it as a child process, so the environment of the test runner cannot leak in.

Troubleshooting environment variables

Symptom Cause Fix
Variables from .env are undefined config() runs after a module already read them, or the file is not in the working directory Load dotenv first, or use node -r dotenv/config; pass { path } if needed
The .env value is ignored The variable already exists in the shell This is intended; unset it in the shell
DEBUG=false still enables debug mode Every value is a string, and "false" is truthy Convert with Joi’s boolean()
Works locally but fails in Docker .env excluded by .dockerignore, as it should be Pass variables with -e or --env-file on docker run
Express shows stack traces in production NODE_ENV is not set Set NODE_ENV=production

That last row deserves a note. Express and many libraries change behaviour when NODE_ENV=production. For example, Express caches view templates and stops sending stack traces in its default error handler. The Express production best practices page lists this as one of the first steps.

When environment variables are the wrong tool

Environment variables suit a handful of flat values per deploy. They are a poor fit for large structured data, such as a list of feature flags with rules or a routing table. For that, a JSON or YAML file in the repository, chosen by an environment variable, works better. Similarly, values that change while the app runs, such as a kill switch, belong in a database or a flag service, because the environment is fixed when the process starts.

FAQ

How do I use environment variables in Node.js?

Read them from process.env, which Node fills when the process starts. Every value is a string, so convert and validate them in one config module at startup, and import that module everywhere else.

Should I commit my .env file?

No. Add .env to .gitignore and commit a .env.example with every key and safe placeholder values. If a real secret was committed, rotate it, because it stays in the history.

Does dotenv override existing environment variables?

No. dotenv only sets variables that are not defined yet. Values from the shell or the hosting platform always win over the file.

Is it safe to store secrets in environment variables?

It is common and acceptable, but not ideal. Child processes and debugging tools can see them. Where possible, mount secrets as files with Docker or Kubernetes, or fetch them from a secrets manager.

Treat configuration as input you validate, just like a request body in an Express REST API. The app then refuses to start with bad settings instead of failing slowly in production. Validate once at startup and import one frozen config object everywhere. Keep real secrets out of the repository, out of images and out of logs.

Last updated on 31 October 2026

Configuration dotenv Environment Variables Node.js Secrets

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *