Skip to content
academia.sh

Lesson 14 / 20

Configuration Management

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

Contents

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:

// 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)}` };
}
// 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.

node try-config.mjs; echo "exit code: $?"
configuration error: required environment variable missing: MEASUREMENTS_ADMIN_KEY
exit code: 78
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:

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.

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:

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:

// 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) });
}
// 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:

node try-layered-config.mjs
listenAddress   127.0.0.1    <- default
listenPort      8791         <- default
logLevel        info         <- default

An environment variable overrides the default:

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:

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:

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.

// 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);
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close