Node.js · Web Servers

Environment Variables

The same code runs on your laptop, in staging and in production — with different databases, keys and ports. Environment variables carry those differences, and keep secrets out of your source code.

Intermediate 6 min Lesson 24 of 37Examples target Node.js 22 LTS.
Before you start:package.json
0/37 in Node.js
On this page · 6 min · Intermediate

Learning objectives#

  • Read configuration from process.env
  • Load .env files with node --env-file or dotenv
  • Validate and type configuration at startup
  • Keep secrets out of Git and logs

Explanation#

process.env holds environment variables as strings: process.env.PORT is "3000", not 3000. Convert and validate.

For local development, put values in a .env file. Node 20.6+ can load it directly: node --env-file=.env server.js. The dotenv package does the same for older setups.

Commit a .env.example with every variable name and safe sample values; add .env to .gitignore.

Centralise configuration in one config.js module that reads, validates and exports a typed object. Fail fast at startup if something required is missing — better than a crash at 3 a.m. on the first payment.

In production, set variables through the platform (Docker, Kubernetes secrets, Azure App Service settings, AWS Secrets Manager), never in files committed to Git. Never log secrets.

Syntax#

Shell Illustrative
# .env  (never committed)
PORT=4000
DATABASE_URL=mysql://shop:secret@localhost:3306/shop
JWT_SECRET=change-me


node --env-file=.env server.js

Code example: A validated config module#

JavaScriptconfig.js Illustrative
// In a real project, start with: node --env-file=.env server.js
// Here we set the variables in code so the example runs anywhere.
process.env.PORT = '4000';
process.env.NODE_ENV = 'production';
process.env.DATABASE_URL = 'mysql://shop:secret@db.internal:3306/shop';
process.env.CORS_ORIGINS = 'https://shop.example.com,https://admin.example.com';
delete process.env.JWT_SECRET;                 // pretend we forgot it


function loadConfig(env = process.env) {
  const errors = [];
  const required = name => {
    const value = env[name];
    if (!value) errors.push(`${name} is required`);
    return value;
  };


  const config = {
    port: Number(env.PORT ?? 3000),
    isProduction: env.NODE_ENV === 'production',
    databaseUrl: required('DATABASE_URL'),
    jwtSecret: required('JWT_SECRET'),
    corsOrigins: (env.CORS_ORIGINS ?? '').split(',').filter(Boolean),
  };


  if (!Number.isInteger(config.port)) errors.push('PORT must be a number');
  if (errors.length) throw new Error('Invalid configuration:\n- ' + errors.join('\n- '));
  return Object.freeze(config);
}


try {
  loadConfig();
} catch (err) {
  console.log(err.message);
}


process.env.JWT_SECRET = 'a-long-random-secret';
const config = loadConfig();
console.log('port:', config.port, typeof config.port);
console.log('production:', config.isProduction);
console.log('origins:', config.corsOrigins.length);
console.log('db host:', new URL(config.databaseUrl).hostname);   // log the host, never the password
Expected output
Invalid configuration:
- JWT_SECRET is required
port: 4000 number
production: true
origins: 2
db host: db.internal

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. All environment variables are strings; PORT is converted with Number.
  2. required() collects missing values instead of failing on the first one.
  3. The first call throws a clear message listing what is wrong.
  4. After setting JWT_SECRET, the config loads and is frozen so nobody can change it at runtime.
  5. Only the database host is logged — never the URL with its password.

Real-world use case#

The same Docker image of an Express API runs in staging and production. Kubernetes injects DATABASE_URL and JWT_SECRET from secrets; if one is missing, the pod fails at startup and the deployment is rolled back automatically.

Common mistakes#

Committing .env

Secrets in Git history stay there forever (and in every clone). Rotate any leaked secret and add .env to .gitignore.

Using env values without conversion

"false" is truthy and "3000" + 1 is "30001". Convert and validate.

Reading process.env everywhere

Scattered reads make it unclear what the app needs. Use one config module.

Best practices#

  • One validated config module
  • .env for local, platform secrets in production
  • Commit .env.example, never .env
  • Fail fast on missing configuration

Remember this#

process.env values are always strings.

node --env-file=.env loads a .env file (Node 20.6+).

Validate config at startup; never log secrets.

Practice exercise#

Your task: Write a function bool(name, defaultValue) that reads process.env[name] and returns true for "true"/"1", false for "false"/"0", the default if missing, and throws for anything else.

Need a hint?
Normalise with toLowerCase() and compare.
Show solution
JavaScript Illustrative
function bool(name, defaultValue) {
  const raw = process.env[name];
  if (raw === undefined) return defaultValue;
  const v = raw.toLowerCase();
  if (v === 'true' || v === '1') return true;
  if (v === 'false' || v === '0') return false;
  throw new Error(`${name} must be true/false, got "${raw}"`);
}


process.env.FEATURE_NEW_CHECKOUT = 'false';
console.log(bool('FEATURE_NEW_CHECKOUT', true));   // false
console.log(bool('FEATURE_MISSING', true));        // true

Mini quiz#

Environment Variables quizMultiple choice Question 1 of 3

What type is process.env.PORT?

Score so far: 0/0

Want more? Practise all Node.js MCQs · Node.js interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.