Skip to content
academia.sh

Lesson 14 / 19

Environment Configuration

How build-time variables enter the output; the constants substitution produces, every value that reaches the client bundle being public, turning the passable-versus-not-passable distinction into an auditable rule, and the trade-off of runtime configuration.

Contents

The previous lesson separated the compile’s two modes, but both modes shared one assumption: the source is the same. The North Slope Measurement Station interface reads data from a test endpoint locally and from the real measurement feed in release; log detail is on locally and off in release; archive writes go to a temporary directory locally and to permanent storage in release. Writing these differences into the source would mean producing two separate outputs from the same source, and at that point there is no longer a single source tree to speak of.

What carries the difference is build-time variables. This lesson first shows how these variables enter the output, then establishes the one critical rule: a value that enters the client bundle is public to everyone.

Two Moments of Configuration

A program running on the server reads its configuration at runtime. As the process starts, it asks the environment for a name, gets the value, and uses it. When the value changes, the process is restarted; the program is not recompiled. Loading environment variables from a file — a .env file that does not go into version control — changes this read’s source, not its timing.

Code running in the browser has no such source. The page does not open inside a process environment; the only thing it can read is the bytes it was given. Two routes remain for configuration to enter client code, and the two work at separate moments.

Build-time substitution. A placeholder in the source is replaced with the value itself during the build. There is no variable in the output; there is a constant. When the value changes, a rebuild is required.

Runtime read. The value is placed not in the output but in the document or a separate endpoint; the client gets it on its first request. When the value changes, a rebuild is not required, but one more read is.

This lesson’s main subject is the first, because the second has a precondition: which value will never be given to the client at all must be decided first.

What Substitution Does

Substitution is a text operation. The transformer below replaces ENV.<NAME> placeholders in the source with the environment table’s values written as code, and produces a single bundle.

// substitute.mjs — build-time environment substitution and searching for the value in the output.
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";

// Source tree: three modules, each reading a placeholder in the form ENV.<NAME>.
const SOURCE = {
  "measurement-client.js":
    'const ENDPOINT = ENV.NORTH_MEASUREMENT_ENDPOINT;\n' +
    'export const fetchMeasurements = (station) =>\n' +
    '  fetch(ENDPOINT + "/station/" + station).then((r) => r.json());\n',
  "log.js":
    'const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };\n' +
    'const LEVEL = ENV.NORTH_LOG_LEVEL;\n' +
    'export const write = (level, message) =>\n' +
    '  ORDER[level] >= ORDER[LEVEL] ? console.log(level + ": " + message) : undefined;\n',
  "archive-writer.js":
    'const KEY = ENV.NORTH_WRITE_KEY;\n' +
    'export const archive = (record) =>\n' +
    '  fetch("/archive", { method: "POST", headers: { "X-Key": KEY },\n' +
    '                       body: JSON.stringify(record) });\n',
};

// The .env file's content: name=value lines, not committed to version control.
const ENV_FILE = [
  "NORTH_MEASUREMENT_ENDPOINT=https://measurement.north-slope.example/v1",
  "NORTH_LOG_LEVEL=warn",
  "NORTH_WRITE_KEY=write-9f21c4a7e08b",
].join("\n") + "\n";

const parseEnv = (text) => Object.fromEntries(text.split("\n")
  .filter((s) => s.includes("="))
  .map((s) => [s.slice(0, s.indexOf("=")).trim(), s.slice(s.indexOf("=") + 1).trim()]));

const env = parseEnv(ENV_FILE);

// Substitution: ENV.<NAME> is replaced with the value written as a source-code constant.
const substitute = (source) => source.replace(/ENV\.([A-Z0-9_]+)/g,
  (full, name) => (name in env ? JSON.stringify(env[name]) : full));

rmSync("output", { recursive: true, force: true });
mkdirSync("output", { recursive: true });
mkdirSync("source", { recursive: true });
writeFileSync(".env.release", ENV_FILE);

let bundle = "";
const changed = [];
for (const [name, body] of Object.entries(SOURCE)) {
  writeFileSync("source/" + name, body);
  const out = substitute(body);
  bundle += "// " + name + "\n" + out + "\n";
  body.split("\n").forEach((line, i) => {
    if (line.includes("ENV.")) changed.push([name, out.split("\n")[i]]);
  });
}
writeFileSync("output/bundle.js", bundle);

console.log("-- substituted lines --");
for (const [name, line] of changed) console.log("  " + name.padEnd(23) + line);

const body = readFileSync("output/bundle.js", "utf8");
console.log("-- value search in the output bundle --");
for (const [name, value] of Object.entries(env))
  console.log("  " + name.padEnd(29) + (body.includes(value) ? "VISIBLE" : "absent"));

The independent search below runs outside the script’s own search, against the produced file.

#!/usr/bin/env bash
# Runs the substitution, then scans the produced bundle directly.
node substitute.mjs
echo "=== independent search ==="
grep -c 'write-9f21c4a7e08b' output/bundle.js
grep -n 'X-Key' output/bundle.js
-- substituted lines --
  measurement-client.js  const ENDPOINT = "https://measurement.north-slope.example/v1";
  log.js                 const LEVEL = "warn";
  archive-writer.js      const KEY = "write-9f21c4a7e08b";
-- value search in the output bundle --
  NORTH_MEASUREMENT_ENDPOINT   VISIBLE
  NORTH_LOG_LEVEL              VISIBLE
  NORTH_WRITE_KEY              VISIBLE
=== independent search ===
1
15:  fetch("/archive", { method: "POST", headers: { "X-Key": KEY },

What the output says is technically ordinary, not in its consequences: all three values sit in the bundle’s bytes as plain text. The write key was behind a name in the source; in the output it is a string constant.

This result is not a flaw, it is the definition of substitution. Because no mechanism exists in the output to resolve a variable, the value has to be written there. For the same reason, there is no build step that hides a substituted value in the output either:

  • Minification does not change values. Name shortening changes local names; string constants stay as they are. The tree shaking from the third lesson finds no reason to eliminate a string either.
  • Encoding is not hiding. Writing the value in base64 or with a simple scramble does not remove it, since its decoder sits in the same bundle; it only adds a step.
  • A source map is a second copy. A source map served publicly in release also carries the original form of the substituted lines.

This is where the lesson’s rule comes from: a value that enters the client bundle is in the hands of everyone who opens the page. A value that enters through substitution and one given at runtime are in the same position.

The Secret-Value Boundary

The rule is not a blocklist, it is a classification. Every configuration value falls into one of two sets, and the distinction is made with a single question: can someone who knows this value do something someone who does not know it cannot?

If the answer is no, the value can pass to the client. This set is general configuration: the addresses of public endpoints, the log level, whether a feature flag is on or off, the release tag, a public tenant or site identifier, the locale list. Knowing these grants no new privilege; it only states what is already observable.

If the answer is yes, the value cannot pass to the client. This set is secret values: server-side write keys, signing secrets, data store connection information, credentials for administrative endpoints, third-party services’ secret keys. Someone who knows these can act with the server’s authority.

The boundary has two consequences, and both are frequently neglected.

Need does not produce an exception. If the client appears to need a secret value, what is actually needed is not the value but the work done with it. The correct fix is moving the work to the server: the client calls an endpoint at its own origin, the session carries the authority, and the secret value stays on the server. This is the correct form of the archive writer above.

A leaked value cannot be taken back. The output has been downloaded and has settled into intermediate caches and browser caches; the fifth lesson also said old names keep living for a while. Rolling back the release does not bring the value back. The only response is revoking the value and replacing it with a new one. This is why the boundary is not something to fix after a leak, but a check that stops the build.

The same reasoning extends to where the build runs. Secret values are given to the environment in a continuous integration pipeline; every line written to that pipeline’s log is visible to everyone who can see that pipeline’s output. The tool doing the substitution printing which names it put into the output is the quietest form of leak.

Auditing the Boundary

The classification is not enforced as long as it stays in a document. For it to be auditable, three things are required: the scope of names declared in one place, the declaration reflected in naming, and the output tested against the declaration.

The auditor below does this with five rules. R1 reports a name that is not declared, R2 a server-scoped name read in client source, R3 a client name that does not carry the public prefix, R4 a secret value found in the output’s bytes, R5 a placeholder that stayed in the output because it has no counterpart in the environment.

// auditor.mjs — an auditor separating a variable open to the client from one that stays on the server.
// Usage: node auditor.mjs initial | node auditor.mjs fixed
const PREFIX = "NORTH_PUBLIC_";

// Declaration: every name's scope is written in one place. The audit reads this table.
const DECLARATION = {
  NORTH_PUBLIC_MEASUREMENT_ENDPOINT: "client",
  NORTH_PUBLIC_LOG_LEVEL: "client",
  NORTH_PUBLIC_RELEASE_TAG: "client",
  NORTH_MEASUREMENT_ENDPOINT: "client",
  NORTH_LOG_LEVEL: "client",
  NORTH_WRITE_KEY: "server",
  NORTH_SIGNING_SECRET: "server",
};

const WRITE = 'const KEY = ENV.NORTH_WRITE_KEY;\n' +
  'export const archive = (record) => request("/archive", KEY, record);\n';

const VERSION = {
  initial: {
    client: {
      "measurement-client.js": 'const ENDPOINT = ENV.NORTH_MEASUREMENT_ENDPOINT;\n',
      "log.js": 'const LEVEL = ENV.NORTH_LOG_LEVEL;\n',
      "footnote.js": 'export const footnote = () => "release " + ENV.NORTH_PUBLIC_RELEASE_TAG;\n',
      "archive-writer.js": WRITE,
    },
    server: {},
    env: {
      NORTH_MEASUREMENT_ENDPOINT: "https://measurement.north-slope.example/v1",
      NORTH_LOG_LEVEL: "warn",
      NORTH_WRITE_KEY: "write-9f21c4a7e08b",
      NORTH_SIGNING_SECRET: "sign-4d70b1cc52ae",
    },
  },
  fixed: {
    client: {
      "measurement-client.js": 'const ENDPOINT = ENV.NORTH_PUBLIC_MEASUREMENT_ENDPOINT;\n',
      "log.js": 'const LEVEL = ENV.NORTH_PUBLIC_LOG_LEVEL;\n',
      "footnote.js": 'export const footnote = () => "release " + ENV.NORTH_PUBLIC_RELEASE_TAG;\n',
      // Archive writing moved to a same-origin endpoint; the key no longer appears in client source.
      "archive-sender.js": 'export const archive = (record) => request("/endpoint/archive", record);\n',
    },
    server: { "archive-writer.js": WRITE },
    env: {
      NORTH_PUBLIC_MEASUREMENT_ENDPOINT: "https://measurement.north-slope.example/v1",
      NORTH_PUBLIC_LOG_LEVEL: "warn",
      NORTH_PUBLIC_RELEASE_TAG: "2026.03",
      NORTH_WRITE_KEY: "write-9f21c4a7e08b",
      NORTH_SIGNING_SECRET: "sign-4d70b1cc52ae",
    },
  },
};

const namesRead = (sources) => [...new Set(Object.values(sources)
  .flatMap((g) => [...g.matchAll(/ENV\.([A-Z0-9_]+)/g)].map(([, name]) => name)))];

const buildBundle = (sources, env) => Object.values(sources).join("")
  .replace(/ENV\.([A-Z0-9_]+)/g, (full, name) => (name in env ? JSON.stringify(env[name]) : full));

function audit(version) {
  const { client, server, env } = VERSION[version];
  const bundle = buildBundle(client, env);
  const findings = [];
  const add = (rule, name, message) => findings.push([rule, name, message]);

  for (const name of namesRead(client)) {
    if (!(name in DECLARATION)) add("R1", name, "not in the declaration");
    else if (DECLARATION[name] === "server") add("R2", name, "server-scoped name read on the client");
    else if (!name.startsWith(PREFIX)) add("R3", name, "client-scoped name does not carry the " + PREFIX + " prefix");
    if (!(name in env)) add("R5", name, "not defined in the environment, placeholder stayed in the output");
  }
  for (const [name, scope] of Object.entries(DECLARATION))
    if (scope === "server" && name in env && bundle.includes(env[name]))
      add("R4", name, "its value was found in the client bundle's bytes");

  console.log("version: " + version);
  console.log("  client bundle: " + Object.keys(client).length + " module, names read: " +
    namesRead(client).join(", "));
  console.log("  server bundle: " + Object.keys(server).length + " module, names read: " +
    (namesRead(server).join(", ") || "-"));
  for (const [rule, name, message] of findings)
    console.log("  " + rule + "  " + name.padEnd(30) + message);
  console.log("  violations: " + findings.length);
  return findings.length;
}

process.exitCode = audit(process.argv[2]) > 0 ? 1 : 0;
#!/usr/bin/env bash
# Audits both versions and prints their exit codes.
node auditor.mjs initial;  echo "exit code: $?"
node auditor.mjs fixed;    echo "exit code: $?"
version: initial
  client bundle: 4 module, names read: NORTH_MEASUREMENT_ENDPOINT, NORTH_LOG_LEVEL, NORTH_PUBLIC_RELEASE_TAG, NORTH_WRITE_KEY
  server bundle: 0 module, names read: -
  R3  NORTH_MEASUREMENT_ENDPOINT    client-scoped name does not carry the NORTH_PUBLIC_ prefix
  R3  NORTH_LOG_LEVEL               client-scoped name does not carry the NORTH_PUBLIC_ prefix
  R5  NORTH_PUBLIC_RELEASE_TAG      not defined in the environment, placeholder stayed in the output
  R2  NORTH_WRITE_KEY               server-scoped name read on the client
  R4  NORTH_WRITE_KEY               its value was found in the client bundle's bytes
  violations: 5
exit code: 1
version: fixed
  client bundle: 4 module, names read: NORTH_PUBLIC_MEASUREMENT_ENDPOINT, NORTH_PUBLIC_LOG_LEVEL, NORTH_PUBLIC_RELEASE_TAG
  server bundle: 1 module, names read: NORTH_WRITE_KEY
  violations: 0
exit code: 0

The initial version’s five findings show three separate classes of defect.

A naming defect. The two R3 findings are not a leak; they are the condition that makes a leak invisible. A public prefix tells a developer reading a name, through the name itself, that this value will enter the output. Without the prefix, the difference between R2 and R3 comes down to a matter of attention.

Missing configuration. R5 reports that a name with no counterpart in the environment stayed in the output unsubstituted. In this case, an undefined reference remains in the output, and the error surfaces at runtime, when that line executes. A defect that could have been caught at build time surfacing on the user’s side is the audit’s reason for existing.

A boundary violation. R2 and R4 are the same problem seen from two different places: one looks at the source, the other at the output’s bytes. Both are needed. The source check cannot catch code that writes the value by hand without touching the name; the byte check cannot say where the name came from.

In the fixed version, the archive writer has been taken out of the client set and moved into the server set. The module that stays on the client calls a same-origin endpoint; the key never enters client source at any stage.

Configuration That Does Not Require a Rebuild

Build-time substitution has a cost, and that cost is the deployment topic’s question: every substituted value binds the output to that environment. If there are five environments, there are five separate build outputs, and the bytes tested in preview are not the bytes that go to release.

Runtime configuration dissolves this bond. Values that vary by environment do not enter the bundle; they are placed in the document as a small section or at a separate endpoint, and the client reads and uses them. The same build output runs in every environment.

The trade-off reads on three measures. Build count drops from one per environment to one. In exchange, the number of sources that must be read before first render grows; if the value comes from a separate endpoint, that is one more network round trip. If the value is embedded in the document, no round trip is added, but the document’s cacheability drops — under the fifth lesson’s policy the document was already revalidating on every use, so these two decisions are consistent with each other.

The selection criterion is this: if a value varies across environments and the same output is meant to run in more than one environment, it is given at runtime; if the value is the same across every environment, or its value needs to affect build decisions — for instance, so dead-code elimination can remove a feature entirely — it is substituted at build time.

The one thing the two routes do not change is the boundary. A value given at runtime is public to everyone the moment it reaches the client too; writing it into the document produces no different security posture than writing it into the bundle.

Summary

  • Code running in the browser has no process environment; configuration reaches it either through build-time substitution or a runtime read.
  • Substitution replaces the placeholder with the value’s constant; in the example, all three values sit in the output bundle’s bytes as plain text.
  • Minification, encoding, and name shortening do not hide values; a source map carries a second copy. A value that enters the client bundle is in everyone’s hands who opens the page.
  • The distinction is made with a single question: if someone who knows the value can do something someone who does not know it cannot, the value is secret and cannot pass to the client; the work moves to the server.
  • The boundary must be auditable: a scope declaration, a public prefix, and a check that looks at both the source and the output bytes stop the build on a violation.
  • Runtime configuration drops the build count to one but adds a read; a leaked secret value can only be met by revoking it.

Next Step

The build output is now fully determined: a directory with its chunks, its assets, its hashed names, and its environment-bound constants. This directory is useless to anyone as long as it sits on a single machine’s disk. The next question is by what route those bytes reach the user’s browser. The simplest route is copying the output as-is and serving it from nodes that sit close to users: no server process runs, every request corresponds to a file. The next lesson builds this route, measures hits against misses in the edge cache, and computes how the fifth lesson’s name-based cache policy translates into a hit ratio.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close