Lesson 08 / 16
Configuration Management
Configuration read from environment variables is defined by a schema; type, range, and option validation happens at startup, every problem is reported at once, and the application's crash with a sysexits exit code on an invalid environment is measured.
Contents
The previous lesson separated the scaffold’s layers, but every value was still written into the code: the port, the loan period, the member loan limit, the store’s location. The same application has to run with different values locally, in a test environment, and in production; as long as those values live in the code, a separate build has to be produced for each environment.
This lesson moves the values out of the code and into environment variables. The real subject is not where the values are read from but when and how they are validated: what should an application started with a missing or wrong-typed variable do?
What Counts as Configuration
Not every constant sitting in the code is configuration. There is a single test that draws the line: a value that changes from environment to environment is configuration. A loan period of fourteen days is the library’s rule, and it is the same in production and locally; a port number, on the other hand, changes from machine to machine.
Applying this test produces three groups:
- Environment-dependent values. The port, the store’s address, the log level, maintenance mode. These are environment variables.
- Business rule constants. The loan period, the member loan limit. These belong to the domain layer and live in the code — but once they need to be tuned across environments, they move into configuration.
- Secrets. The database password, the signing key. These also come from the environment, but they form a separate class and are the subject of the next lesson.
The schema below defines the first two groups together; the loan period and member limit have been pulled into configuration so that they stay open to trying different values across environments, and their defaults are the business rule itself.
Schema, Default, and Validation
A configuration reader has three jobs: read the value, validate its type, and fall back to a default when it is missing. The module below does all three through a schema. Environment variables share a single prefix; the prefix keeps variables that do not belong to the application from leaking into the schema.
// src/setup/configuration.mjs — reads from the environment, validates everything, returns a frozen object const PREFIX = "LIBRARY_"; export const SCHEMA = { PORT: { type: "number", default: 8435, min: 1024, max: 65535 }, LOAN_DAYS: { type: "number", default: 14, min: 1, max: 90 }, MEMBER_LOAN_LIMIT: { type: "number", default: 5, min: 1, max: 50 }, STORE_ADDRESS: { type: "text", required: true }, LOG_LEVEL: { type: "option", options: ["error", "warning", "info", "verbose"], default: "info" }, MAINTENANCE_MODE: { type: "boolean", default: false }, }; const convert = (name, rule, raw) => { if (rule.type === "number") { const number = Number(raw); if (!Number.isInteger(number)) return { error: `expected an integer, got "${raw}"` }; if (number < rule.min || number > rule.max) { return { error: `must be within ${rule.min}-${rule.max}, got ${number}` }; } return { value: number }; } if (rule.type === "boolean") { if (raw !== "true" && raw !== "false") { return { error: `expected "true" or "false", got "${raw}"` }; } return { value: raw === "true" }; } if (rule.type === "option") { if (!rule.options.includes(raw)) { return { error: `options [${rule.options.join(", ")}], got "${raw}"` }; } return { value: raw }; } return raw.length > 0 ? { value: raw } : { error: "cannot be empty" }; }; export const readConfiguration = (env) => { const issues = []; const settings = {}; for (const [name, rule] of Object.entries(SCHEMA)) { const key = PREFIX + name; const raw = env[key]; if (raw === undefined) { if (rule.required) issues.push(`${key}: required, not defined`); else settings[name] = rule.default; continue; } const result = convert(name, rule, raw); if (result.error) issues.push(`${key}: ${result.error}`); else settings[name] = result.value; } for (const key of Object.keys(env)) { // typos should not pass through silently if (key.startsWith(PREFIX) && !(key.slice(PREFIX.length) in SCHEMA)) { issues.push(`${key}: unknown key`); } } if (issues.length > 0) { const error = new Error("configuration invalid"); error.issues = issues.sort(); throw error; } return Object.freeze(settings); };
Three design decisions are hiding in here, and all three will show up in the measurement.
Every problem is collected. The reader does not stop at the first error; it walks the whole schema, gathers problems into a list, and reports them all at once at the end. This is how you avoid having to start an environment with eight wrong variables eight separate times.
An unknown key is an error. A variable that carries the prefix but has no match in the schema is almost always a typo. Ignored silently, the application would start, run on a default, and the reason for the wrong behavior would be hunted for in the environment file for hours.
The result is frozen. The Object.freeze call makes the configuration immutable. Code
that changes a setting during execution makes it unclear what value the process is running
with at any given moment; freezing closes off that path.
Crashing at Startup
What determines the value of validation is when it happens. If configuration is read while serving a request, the error surfaces on the first request — maybe hours after release. If it is read at startup, an invalid environment never serves a single request.
The startup file below applies this: configuration is read before the server is built; if it is invalid, the server is never created at all.
// src/setup/start.mjs — startup: configuration is read first; if invalid, the server is never built import { createServer } from "node:http"; import { readConfiguration } from "./configuration.mjs"; const EX_CONFIG = 78; // sysexits.h: configuration error let settings; try { settings = readConfiguration(process.env); } catch (error) { console.error(error.message + ":"); for (const issue of error.issues ?? []) console.error(" - " + issue); process.exit(EX_CONFIG); } console.log("configuration: " + JSON.stringify(settings)); try { settings.LOAN_DAYS = 30; // frozen object: write attempt } catch (error) { console.log("settings mutation attempt: " + error.constructor.name); } createServer((req, res) => { res.sendDate = false; res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ loanDays: settings.LOAN_DAYS, maintenance: settings.MAINTENANCE_MODE })); }).listen(settings.PORT, "127.0.0.1", () => console.log(`started 127.0.0.1:${settings.PORT}`));
The exit code was not picked at random. The sysexits.h header defines EX_CONFIG as 78,
meaning “configuration error.” A supervisor watching the application sees this code and knows
that restarting the process will not help: the same environment will produce the same error.
The measurement tries startup with four environments. The env -i call clears the environment,
so variables already sitting in the shell do not leak into the result.
#!/usr/bin/env bash # Tries startup with four different environments; prints the message and exit code for each attempt. try() { # $1 = description, remaining args are KEY=value pairs description="$1"; shift echo "=== $description ===" env -i PATH="$PATH" "$@" node src/setup/start.mjs & pid=$! sleep 0.8 if kill -0 "$pid" 2>/dev/null; then curl -sS "http://127.0.0.1:8435/"; echo kill "$pid"; echo "process still up, terminated" else wait "$pid"; echo "exit code=$?" fi } try "1. required variable missing" try "2. type and option error together" \ LIBRARY_STORE_ADDRESS=file:./data/library.db \ LIBRARY_PORT=eighty \ LIBRARY_LOG_LEVEL=detailed \ LIBRARY_LOAN_DAY=14 try "3. out-of-range value" \ LIBRARY_STORE_ADDRESS=file:./data/library.db \ LIBRARY_PORT=80 try "4. valid environment (defaults fill in the rest)" \ LIBRARY_STORE_ADDRESS=file:./data/library.db \ LIBRARY_MAINTENANCE_MODE=true
=== 1. required variable missing ===
configuration invalid:
- LIBRARY_STORE_ADDRESS: required, not defined
exit code=78
=== 2. type and option error together ===
configuration invalid:
- LIBRARY_LOAN_DAY: unknown key
- LIBRARY_LOG_LEVEL: options [error, warning, info, verbose], got "detailed"
- LIBRARY_PORT: expected an integer, got "eighty"
exit code=78
=== 3. out-of-range value ===
configuration invalid:
- LIBRARY_PORT: must be within 1024-65535, got 80
exit code=78
=== 4. valid environment (defaults fill in the rest) ===
configuration: {"PORT":8435,"LOAN_DAYS":14,"MEMBER_LOAN_LIMIT":5,"STORE_ADDRESS":"file:./data/library.db","LOG_LEVEL":"info","MAINTENANCE_MODE":true}
settings mutation attempt: TypeError
started 127.0.0.1:8435
{"loanDays":14,"maintenance":true}
process still up, terminated
What the Measurement Shows
The first attempt shows a required variable missing. The application never started, never
listened on the port, and exited with 78. Which value is missing is written by name; a
message that just says “something went wrong” tells the person who has to fix the environment
nothing.
The second attempt reports three separate problems at once. PORT could not be converted
to a number, LOG_LEVEL was not on the option list, and LIBRARY_LOAN_DAY was flagged as an
unknown key. The third one is a variable written as LOAN_DAY instead of LOAN_DAYS; had the
schema not caught it, the application would have started and the loan period would have
silently stayed on its default. Having the option list itself written into the message means
the fix can be made without checking any documentation.
The third attempt separates the case where the type is right but the value is wrong. 80
is a valid integer, but it falls in the range of privileged ports. Type checking alone is not
enough; a meaningful range is also part of the schema.
The fourth attempt shows a valid environment. Only two variables were given; the other four were filled in by their defaults, and the full configuration was printed on a single line. Writing the configuration to the log at startup removes any later need to ask what settings a process is running with.
In the same output, the line settings mutation attempt: TypeError confirms that the attempt
to write to the frozen object was not silently swallowed. Because the module’s code runs in
strict mode, the assignment throws an error; outside strict mode, the same assignment would
have been silently ignored.
Precedence Order and the Environment File
An environment variable is not the only source. The layered configuration arrangement set up in the Configuration Management lesson of the Node.js Runtime course holds here too: a value can be defined in more than one place, and which one wins has to be decided in advance. A common order, left to right in increasing priority, is: the schema’s default, an environment file, the actual environment variable, a command-line option.
This reader uses only two layers — default and environment variable — but the order is open to
extension: because the readConfiguration function takes the environment as a parameter, the
caller can merge the layers and hand it a single object. The function deliberately does not
read process.env on its own; that is what lets different environments be tried in tests
without polluting the real one.
The environment file itself is a convenience, and it has a limit: it makes local development comfortable, but in production it hides where values are coming from. That is also why the file does not go into version control, and it marks the boundary of the next lesson.
Summary
- A value that changes from environment to environment is configuration; a rule that does not change belongs to the code, and secrets belong to a separate class.
- The schema defines every variable’s type, range, options, and default in one place; the reader does not stop at the first error, it collects every problem and reports them all at once.
- A key that carries the prefix but has no match in the schema is treated as an error; in the
measurement,
LIBRARY_LOAN_DAYwas a typo caught by this rule. - Validation happens at startup: on an invalid environment the server is never built, and the
process exits with the
sysexits.hheader’sEX_CONFIGvalue,78. - The configuration that is read is frozen; an attempt to mutate it in code running in strict
mode produces a
TypeError. Settings left out are filled in by their defaults and written to the log at startup.
Next Step
One variable was deliberately missing from this lesson’s schema: the store’s password. The port and the password are read from the same place, the environment; even so, one can be written to the log and the other cannot. The next lesson draws that line: what is the difference between configuration and a secret, which rules keep a secret value from reaching the log, an error response, and the client, and how is redaction actually verified to be in place?
To keep your progress and take notes, Log in
My notes
Log in to take notes.