---
title: 'Environment Configuration'
source: 'https://academia.sh/en/courses/rendering-strategies/environment-configuration'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```bash
#!/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.

```js
// 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;
```

```bash
#!/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.
