---
title: 'Configuration Management'
source: 'https://academia.sh/en/courses/nodejs/configuration-management'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:51+00:00'
license: 'CC BY-SA 4.0'
---

# Configuration Management

Separating settings from source code, validating environment variables, precedence order, separating secrets from ordinary settings, and keeping configuration immutable.

The tool from the previous lesson and the service from the HTTP Server lesson still
have hardcoded values: port 8791, the data file's name, the body-size limit. Correct
on a development machine, wrong somewhere else.

This lesson's question: where should a setting's value come from, and what happens
when it is wrong? The answer has two halves — the order of sources and the timing of
validation.

## Settings Are Separated from Source Code

Every value that varies by environment is pulled out of the code. The test is
simple: the same build has to run unchanged in another environment. Changing the
environment without rebuilding guarantees the code running is the code that was
tested.

Environment variables carry settings. As seen in the Process Object lesson, these
are inherited when the process starts and read through `process.env`; their values
are always strings.

Reading and validation are gathered into a single module. The rest of the program
never touches `process.env`:

```js
// config.mjs
function requireEnv(name) {
  const value = process.env[name];
  if (value === undefined || value === '') {
    throw new Error(`required environment variable missing: ${name}`);
  }
  return value;
}

function integer(name, fallback) {
  const raw = process.env[name];
  if (raw === undefined) return fallback;
  const number = Number(raw);
  if (!Number.isInteger(number) || number <= 0) {
    throw new Error(`${name} must be a positive integer, got: ${JSON.stringify(raw)}`);
  }
  return number;
}

export function readConfig() {
  return Object.freeze({
    listenAddress: process.env.MEASUREMENTS_ADDRESS ?? '127.0.0.1',
    listenPort: integer('MEASUREMENTS_PORT', 8791),
    dataDir: process.env.MEASUREMENTS_DATA ?? './data',
    logLevel: process.env.MEASUREMENTS_LOG_LEVEL ?? 'info',
    adminKey: requireEnv('MEASUREMENTS_ADMIN_KEY'),
  });
}

// Secrets are never printed directly
export function printableView(config) {
  return { ...config, adminKey: `***${config.adminKey.slice(-2)}` };
}
```

```js
// try-config.mjs
import { readConfig, printableView } from './config.mjs';
try {
  console.log(printableView(readConfig()));
} catch (error) {
  console.error('configuration error:', error.message);
  process.exitCode = 78;
}
```

The variable names sharing a common prefix is deliberate: the process inherits
dozens of unrelated variables from the shell that started it, and the prefix removes
any ambiguity about which belong to this program.

## Validation Happens at Startup

The worst moment for a wrong setting to surface is the first time its code path
runs, hours into the service running. Configuration is read and validated **at
startup**, before any request is accepted; if invalid, the process never comes up.

```sh
node try-config.mjs; echo "exit code: $?"
```

```
configuration error: required environment variable missing: MEASUREMENTS_ADMIN_KEY
exit code: 78
```

```sh
MEASUREMENTS_ADMIN_KEY=abc123XYZ MEASUREMENTS_PORT=eight node try-config.mjs; echo "exit code: $?"
```

```
configuration error: MEASUREMENTS_PORT must be a positive integer, got: "eight"
exit code: 78
```

Both messages state what's missing and the expected form. Configuration errors are
meant to be read by a person; a message like "invalid configuration" gives someone
looking for the fix nothing to go on.

Startup with correct values:

```sh
MEASUREMENTS_ADMIN_KEY=abc123XYZ MEASUREMENTS_PORT=9100 node try-config.mjs
```

```
{
  listenAddress: '127.0.0.1',
  listenPort: 9100,
  dataDir: './data',
  logLevel: 'info',
  adminKey: '***YZ'
}
```

The value `9100`, arriving as a string, was converted to a number and verified as an
integer. Converting in one place means the rest of the program never asks the type
question.

## Precedence Order

A setting can come from more than one source. The order is fixed and documented
from the start; the most common arrangement, from narrowest to widest, is:

1. Command-line argument — affects a single run.
2. Environment variable — affects that process.
3. Env file — affects every run in a directory.
4. Default in code — applies when none of the above is given.

Each level overrides the one below it. The runtime directly supports the third
level: from a certain version onward, `NAME=value` lines in a file given with a flag
load into `process.env`.

```sh
printf 'MEASUREMENTS_ADMIN_KEY=fromfile99\nMEASUREMENTS_PORT=9200\n' > .env
node --env-file=.env try-config.mjs
```

```
{
  listenAddress: '127.0.0.1',
  listenPort: 9200,
  dataDir: './data',
  logLevel: 'info',
  adminKey: '***99'
}
```

A variable defined in the environment overrides the value from the file:

```sh
MEASUREMENTS_PORT=9999 node --env-file=.env try-config.mjs
```

```
{
  listenAddress: '127.0.0.1',
  listenPort: 9999,
  dataDir: './data',
  logLevel: 'info',
  adminKey: '***99'
}
```

The env file is a development convenience. It is not checked into version control;
the repository root keeps only an example file with field names and descriptions,
and the real file goes on the ignore list — exactly what the ignore mechanism from
the Introduction to Version Control course exists for.

## Implementing the Top Level

The list's first item is not in the code yet: the reader only looks at the
environment, not command-line arguments. This level redirects a single run — a
diagnostic session, a test startup — without changing the environment.

The reader that merges the layers in one place also records which source won for
each field:

```js
// layered-config.mjs
import { parseArgs } from 'node:util';

const DEFINITIONS = {
  listenAddress: { option: 'address',    variable: 'MEASUREMENTS_ADDRESS',   type: 'string',  fallback: '127.0.0.1' },
  listenPort:    { option: 'port',       variable: 'MEASUREMENTS_PORT',      type: 'integer', fallback: 8791 },
  logLevel:      { option: 'log-level',  variable: 'MEASUREMENTS_LOG_LEVEL', type: 'string',  fallback: 'info' },
};

function convert(sourceName, raw, type) {
  if (type === 'string') return raw;
  const number = Number(raw);
  if (!Number.isInteger(number) || number <= 0) {
    throw new Error(`${sourceName} must be a positive integer, got: ${JSON.stringify(raw)}`);
  }
  return number;
}

// Arguments and environment are passed in: testing never touches the real process
export function readConfig(args = process.argv.slice(2), env = process.env) {
  const { values } = parseArgs({
    args,
    options: Object.fromEntries(Object.values(DEFINITIONS).map((d) => [d.option, { type: 'string' }])),
    strict: true,
  });

  const value = {};
  const source = {};
  for (const [field, d] of Object.entries(DEFINITIONS)) {
    if (values[d.option] !== undefined) {
      value[field] = convert(`--${d.option}`, values[d.option], d.type);
      source[field] = 'argument';
    } else if (env[d.variable] !== undefined && env[d.variable] !== '') {
      value[field] = convert(d.variable, env[d.variable], d.type);
      source[field] = 'environment';
    } else {
      value[field] = d.fallback;
      source[field] = 'default';
    }
  }
  return Object.freeze({ value: Object.freeze(value), source: Object.freeze(source) });
}
```

```js
// try-layered-config.mjs
import { readConfig } from './layered-config.mjs';

try {
  const { value, source } = readConfig();
  for (const field of Object.keys(value)) {
    console.log(`${field.padEnd(15)} ${String(value[field]).padEnd(12)} <- ${source[field]}`);
  }
} catch (error) {
  console.error('configuration error:', error.message);
  process.exitCode = 78;
}
```

When no source is given, every field falls back to the default in code:

```sh
node try-layered-config.mjs
```

```
listenAddress   127.0.0.1    <- default
listenPort      8791         <- default
logLevel        info         <- default
```

An environment variable overrides the default:

```sh
MEASUREMENTS_PORT=9100 MEASUREMENTS_LOG_LEVEL=verbose node try-layered-config.mjs
```

```
listenAddress   127.0.0.1    <- default
listenPort      9100         <- environment
logLevel        verbose      <- environment
```

An argument overrides the environment too:

```sh
MEASUREMENTS_PORT=9100 node try-layered-config.mjs --port 9500
```

```
listenAddress   127.0.0.1    <- default
listenPort      9500         <- argument
logLevel        info         <- default
```

Validation stays the same at every layer; whichever source's name the error message
carries is where the fix belongs:

```sh
node try-layered-config.mjs --port eight; echo "exit code: $?"
```

```
configuration error: --port must be a positive integer, got: "eight"
exit code: 78
```

Keeping the source record looks like an extra field, but it is decisive for
diagnosis: an unexpected value comes either from a wrong value being given or from
the expected layer never being read. The source field separates the two, and this
information can go into the startup log — for fields that are not secret.

The reader taking the argument array and the environment as parameters is also a
testability decision: a test can try every combination of layers without touching
real process state. This design returns in the Test lesson.

## Secrets Are Handled Separately

An admin key and a port number are not in the same class. The second can be written
to diagnostic output, error messages, and logs; the first cannot.

Applying this rests on two rules. First, the configuration object is never printed
directly; `printableView` above masks the secret field and leaves only the last two
characters — enough to recognize the wrong key was loaded, not enough to give it
away.

Second, the secret is never passed as an argument. Command-line arguments show up
in the machine's process list; environment variables do not. This is why secrets
are only ever read from the environment or a file.

Error objects need attention too: a connection error object can carry the
connection string in one of its fields. Writing that object to the log as is makes
the masking pointless — why the Logging lesson chooses log fields deliberately.

## Configuration Is Immutable

The configuration object is read once and frozen. A setting that can change while
running lets the same request show two different behaviors and makes diagnosis
impossible.

```js
// freeze-config.mjs
import { readConfig } from './config.mjs';

const config = readConfig();
console.log('listen port      :', config.listenPort);
console.log('is frozen        :', Object.isFrozen(config));

try {
  config.listenPort = 1234;      // ES modules run in strict mode
} catch (error) {
  console.log('write attempt    :', error.constructor.name);
}
console.log('value unchanged  :', config.listenPort);
```

```sh
MEASUREMENTS_ADMIN_KEY=abc123XYZ node freeze-config.mjs
```

```
listen port      : 8791
is frozen        : true
write attempt    : TypeError
value unchanged  : 8791
```

Freezing is one of the immutability techniques from the Objects and Functions in
JavaScript course. Because ES modules run in strict mode, a write on a frozen
object is not silently ignored; it throws, and the code that got it wrong shows up
immediately.

Freezing is shallow: nested objects have to be frozen separately. The configuration
above carries only primitive values, so a single call is enough.

## Summary

- Every value that varies by environment is pulled out of the code; the same build
  has to run in another environment unchanged.
- Configuration is read and validated in a single module; the rest of the program
  never touches the environment object and never asks the type question.
- Validation happens at startup, and the process never comes up if it fails; the
  error message states the missing field and the expected form.
- The source precedence order is fixed from the start: argument, environment
  variable, env file, default in code; recording the winning source for each field
  shortens diagnosis.
- Secrets are masked before printing and never passed as arguments; command-line
  arguments show up in the process list.

## Next Step

A configuration error was one that stopped the process from coming up. Errors that
occur while running do not call for the same behavior: a request's malformed body
should not shut down the service, but a corrupted in-memory state should. The next
lesson separates these two classes and decides which error gets caught and which
one ends the process.
