Lesson 03 / 14
Configuration and Environment Management
Configuration and environment management lets the same app run with different values in development, staging, and production. The lesson measures that a flag opened from its default leaves the feature full.
Published Updated
In this lesson
- Explains why development, staging, and production environments are kept apart
- Describes how a secret is carried without being embedded in the build
- Relates remote configuration, feature flags, and default values
- Measures that a flag that comes from the default leaves the feature full
Contents
In the previous lesson appearance was adapted per platform. In this lesson what changes is not the platform but the environment the app runs in. The same build runs on the developer’s machine, in the hands of the testing team, and with a real user; in none of the three does it talk to the same server, write the same log verbosity, or find the same flags open. Configuration is the mechanism that carries this difference without touching the code. The lesson first describes the parts of configuration, then measures how well a feature works on a first launch where remote configuration cannot be reached.
Environments and Configuration Layers
An environment names the purpose for which the app runs and the backend it runs against. Three environments are common: the development environment belongs to a single developer and allows everything to be tried quickly; the staging environment resembles production but carries no real data; the production environment is the one real users use. The gain of separating environments is catching a mistake in the right place: a change is tested first in development, then in staging, and only then goes to production.
Each environment has its own configuration: a server address, a log verbosity, a service key. Writing these values into the code causes two problems. First, when the environment changes the code has to change, and when the code changes a tested build is no longer a tested build. Second, values such as keys enter the repository together with the code. For this reason the values are separated from the code and selected by environment during the build.
The snippet below shows this separation in a configuration file. The secret itself does not stay in the file; only its name does, and the real value is substituted from the build system’s secret store. The snippet is not measured.
# Platform code: per-environment configuration environments: staging: api_host: api.staging.example.test api_key: ${API_KEY} # substituted from the secret store at build time log_level: verbose production: api_host: api.example.test api_key: ${API_KEY} log_level: plain defaults: search: true # the value in effect if remote configuration does not arrive banner: false
A secret is a value whose exposure would lead to the misuse of a service: a key, a password, a signing key. The rule is simple and applied without exception: a secret is not embedded in the code, in the configuration file, or in the app package. Everything that enters the package can be read by anyone who opens the package. The secret stays in the build system’s secret store, and the configuration carries only its name. A value that truly must not stay on the client is never sent to the client at all; it stays on the server.
Default Values and Remote Configuration
Some values cannot be fixed at build time, because they need to change after the user has installed the app. Remote configuration is the app receiving values from a server while it runs. Its most common use is the feature flag: a value that decides, without a code change, whether a feature is on or off. A special kind of flag is the remote kill flag: a flag that turns off a feature causing problems in production without waiting for a new version.
Remote configuration has a flaw: the server may be unreachable. On a first launch there may be no network, the server may temporarily not respond, the user may be in airplane mode. In this case the app must have a value. This is what the default value is: the value that ships inside the package and applies when nothing can be received remotely. The app resolves a value in this order: the remote value if there is one, otherwise the default.
Choosing the default is a product decision and carries risk in two directions. If the default is on, the feature works even when remote configuration does not arrive; but a feature that should be turned off stays on at first launch. If the default is off, the feature is safely off; but on first launch the user never sees the feature. The lesson measures how this choice is reflected in operability.
From Condition to Capability
The model turns configuration into a condition. configFor(name, remote, defaults) resolves the environment’s flags: it takes the remote value if there is one, otherwise the default. Every open flag enters the condition’s has list as a capability of the form flag:<name>. If a feature needs a flag, it asks for that capability in its needs list. The lesson’s core is given again below, as in every lesson.
// operability.mjs — classifies how fully a feature works under a condition export const LEVELS = ["full", "degraded", "deferred", "disabled"]; const FALLBACKS = ["degraded", "deferred"]; export function feature(name, needs, fallback = {}) { return { name, needs, fallback }; } export function condition(name, has) { return { name, has }; } function judge(item, cond) { const missing = item.needs.filter((need) => !cond.has.includes(need)).sort(); let level = "full"; for (const need of missing) { const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled"; if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given; } return { level, feature: item.name, condition: cond.name, missing }; } export function assess(features, conditions) { const records = []; for (const cond of conditions) { for (const item of features) records.push(judge(item, cond)); } return records; } export function printLevels(records) { for (const r of records) { const missing = r.missing.length === 0 ? "-" : r.missing.join(","); console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`); } } export function countLevels(records) { for (const name of LEVELS) { console.log(`${name}: ${records.filter((r) => r.level === name).length}`); } console.log(`affected: ${records.filter((r) => r.level !== "full").length}`); }
The configuration helper is a lesson-local file. The environment table holds not the secret but its name.
// config.mjs — environment table, default values, and flag resolution import { condition } from "./operability.mjs"; export const ENVS = { dev: { host: "dev.example.test", logs: "verbose", secret: "ref:API_KEY_DEV" }, staging: { host: "staging.example.test", logs: "verbose", secret: "ref:API_KEY_STG" }, prod: { host: "api.example.test", logs: "plain", secret: "ref:API_KEY_PROD" }, }; export const DEFAULTS = { search: true, banner: false }; export function configFor(name, remote = null, defaults = DEFAULTS) { const flags = { ...defaults, ...(remote ?? {}) }; const has = Object.keys(flags) .filter((key) => flags[key]) .sort() .map((key) => `flag:${key}`); return condition(name, has); }
The first measurement is not about operability; it prints the table of the three environments.
// main.mjs import { ENVS } from "./config.mjs"; for (const name of Object.keys(ENVS)) { const env = ENVS[name]; console.log(`${name}: host=${env.host} logs=${env.logs} secret=${env.secret}`); }
dev: host=dev.example.test logs=verbose secret=ref:API_KEY_DEV staging: host=staging.example.test logs=verbose secret=ref:API_KEY_STG prod: host=api.example.test logs=plain secret=ref:API_KEY_PROD
The secret field is not a value but the name of a record in the secret store. The three environments point to three separate secrets, and none of their values appears in the table.
SA5. The field names, server addresses, and secret names are the lesson’s construction, and the example.test domain does not reach a real server. The model does not include resolving a secret, receiving remote configuration over the network, or how a flag is defined on the server; it establishes only that a flag is on or off and that this enters the condition as a capability.
The Remote Kill Flag
The second measurement tests two features in the production environment in two states. Search is the new search feature and needs flag:search. Banner needs flag:banner and falls back to a plain layout when the flag is missing. In the first condition remote configuration could not be reached at all, and only the defaults apply. In the second condition remote configuration has arrived and turned the search flag off.
// main.mjs import { feature, assess, printLevels } from "./operability.mjs"; import { configFor } from "./config.mjs"; const features = [ feature("Search", ["flag:search"]), feature("Banner", ["flag:banner"], { "flag:banner": "degraded" }), ]; const conditions = [ configFor("prod-default"), configFor("prod-remote", { search: false }), ]; printLevels(assess(features, conditions));
full: Search @ prod-default missing=- degraded: Banner @ prod-default missing=flag:banner disabled: Search @ prod-remote missing=flag:search degraded: Banner @ prod-remote missing=flag:banner
Three things are visible. Under the default condition Search is full, because search is on in the defaults. Banner is off in the defaults; but it has a fallback, the plain layout, so it is degraded and not disabled. When the remote kill flag arrives, Search drops to disabled, because no fallback is defined for search. This is also the purpose of a remote kill flag: to take a feature out of service without waiting for a new version.
SA6. The flag names and their default values are the lesson’s construction. In a real app, what the search feature shows while it is off is a separate product decision; the model knows only whether a fallback exists.
The Bounding Measurement
The lesson’s focus record is Search @ prod-default: on first launch, while remote configuration cannot be reached, the new search flag comes from the default. The default is on, so the capability exists and the record is full. For comparison, a sibling of the same condition whose default is off is added.
// main.mjs import { feature, assess, printLevels } from "./operability.mjs"; import { configFor, DEFAULTS } from "./config.mjs"; const features = [feature("Search", ["flag:search"])]; const conditions = [ configFor("prod-default"), configFor("prod-default-off", null, { ...DEFAULTS, search: false }), ]; printLevels(assess(features, conditions));
full: Search @ prod-default missing=- disabled: Search @ prod-default-off missing=flag:search
With the default on, the record is full; with it off, the record is disabled. The difference is the value itself: an open flag enters the condition as a capability, and a closed flag does not.
This result differs from the question of M31/K04. That lesson asked about the origin of a read: did the answer come from memory, from disk, from the network, or from a default. Even when the value of remote configuration comes from the default, the feature can be full; operability classifies not the source of the value but the presence of the capability. The measurement below shows this directly. The same flag comes from the default under one condition and from remote under the other, and the two records are compared.
// main.mjs import { feature, assess } from "./operability.mjs"; import { configFor } from "./config.mjs"; const features = [feature("Search", ["flag:search"])]; const fromDefault = assess(features, [configFor("prod")]); const fromRemote = assess(features, [configFor("prod", { search: true })]); console.log(JSON.stringify(fromDefault) === JSON.stringify(fromRemote) ? "record unchanged" : "record changed");
record unchanged
The value came from the default under one condition and from remote under the other; the record stayed the same. The source does not enter the record. This does not mean the source is unimportant: a team has to choose the default safely and know the delay of the remote value. But these are questions about the origin of a read; operability asks the question from somewhere else: how well does this feature work under this condition?
Summary
- This lesson’s operability: full. In the
Search @ prod-defaultrecord, even though remote configuration cannot be reached, the search flag is on from the default and the capability exists. - If the same flag’s default were off,
Search @ prod-default-offwould come out disabled; the difference is not in the source of the value but in the presence of the capability. - The same open value coming from the default and from remote gave the same record; operability does not put the source of the value into the record.
- When the remote kill flag arrived,
Search, which has no fallback, dropped to disabled andBanner, which has a fallback, to degraded. - A secret is not embedded in the code, in the configuration file, or in the package; the configuration carries only its name.
Next Step
In this lesson the capability came from a configuration value and the feature stayed full. The next lesson, Accessibility, covers the case where the capability drops because of the user’s own setting: when a screen reader is turned on, a feature can drop to degraded without a single node changing in the view tree.
To keep your progress and take notes, Log in
My notes
Log in to take notes.