Configuration that is easy to find, read, and diff is more than a preference; it is how a change stays reviewable. Naming a file with a clear convention is the first step to environment-specific, single-source config.
A file is a record of intent
A named, versioned config file is a record of intent: its committed values become explicit and reviewable like code. Project history and pull requests show what changed. To establish what actually ran, also record the deployed revision and runtime overrides; the repository alone cannot reveal them. A clear naming convention (app.conf for application knobs, app.env for environment shape) makes each file’s purpose obvious at a glance, and keeps the settings from being scattered into ad-hoc flags nobody can find.
Env and files solve different problems
Environment variables and files solve different problems. Variables are ideal for deploying the same build into many places without editing an image — you vary the value at run time per environment. Files are better for rich, structured settings a variable string would mangle. Most systems combine both: files keep the structure, the environment injects the values that differ per deploy. The rule that keeps it honest is that the source of truth stays explicit, and the boundary between structure and injectable values is drawn deliberately rather than by accident.
Secrets still move outside
Putting a config file in a repo is not a license to commit secrets into it. Anything that is a credential should be injected or referenced from a secrets store at run time, never baked into the file you commit and diff. The clean split is shape in files, secrets in a store: the committed config stays safe to show while secret storage, transport, and access controls need their own configuration. Environment injection is not encryption, and logging effective configuration can expose credentials. Report key names and winning sources while redacting sensitive values.
When it breaks
Config names drift and the loader picks the wrong file. A deploy writes app-prod.json while the program opens app.prod.json, so the expected file is missing and the loader quietly falls back to a stale development file. The service starts, talks to the wrong database, and nothing in the diff explains it, because the real mistake is the filename rather than any value. Fail closed instead: resolve one explicit path at startup, log that path, and exit with file-not-found rather than guessing a sibling.
Environment overrides fail silently in the other direction. An empty APP_PORT= or a misspelled APP-PORT either overrides a good file value with an empty string or overrides nothing at all, depending on the loader, and the running value no longer matches any committed file. Because the change leaves no diff, the team re-reads the file and concludes nothing changed. Record the winning source for every key — file, environment, or flag — print it at startup, and reject empty values instead of treating them as real input.
Secrets leak when a convenient file becomes the real store. Someone commits a working .env carrying a production credential to fix staging once, removes it in the next commit, and assumes the problem is gone. It is not: the secret lives on in history, mirrors, and backups, and every clone made in between still carries it. Committed files should hold shape with placeholders such as password = "from-store", real values should arrive only from a secrets store or the runtime environment, and a scanner should block the commit before it lands.
Check it quickly
Save the following as config_precedence_probe.py and run python3 config_precedence_probe.py. It requires Python 3.7 or newer, uses only the standard library, and writes temporary JSON fixtures without touching real config. Run without -O, -OO, or a nonzero PYTHONOPTIMIZE setting so the assertions execute. The good sample starts from {"host": "127.0.0.1", "port": 8000, "mode": "dev"}, supplies APP_PORT=9001 in an environment-shaped dictionary, then --mode prod in an argument list, and asserts both the merged result and the per-key source map.
import argparse
import json
from pathlib import Path
from tempfile import TemporaryDirectory
KEYS = {"host", "port", "mode"}
ENV = {"APP_HOST": "host", "APP_PORT": "port", "APP_MODE": "mode"}
def validate(key, value):
if isinstance(value, bool) or value is None or not isinstance(value, (str, int)):
raise ValueError("invalid type: " + key)
text = str(value).strip()
if not text:
raise ValueError("empty value: " + key)
if key == "port":
if not text.isascii() or not text.isdecimal() or not 1 <= int(text) <= 65535:
raise ValueError("invalid port")
return int(text)
if key == "mode" and text not in {"dev", "prod"}:
raise ValueError("invalid mode")
if not isinstance(value, str):
raise ValueError("text required: " + key)
return text
def load(path, env, argv):
parser = argparse.ArgumentParser()
for key in sorted(KEYS):
parser.add_argument("--" + key)
cli = {k: v for k, v in vars(parser.parse_args(argv)).items() if v is not None}
file = json.loads(Path(path).read_text())
if not isinstance(file, dict) or set(file) - KEYS:
raise ValueError("unknown file keys or non-object config")
if any(k.startswith("APP_") and k not in ENV for k in env):
raise ValueError("unknown application environment key")
merged, sources = {}, {}
for label, values in [("file", file), ("env", {ENV[k]: v for k, v in env.items() if k in ENV}), ("cli", cli)]:
for key, value in values.items():
merged[key], sources[key] = validate(key, value), label
if set(merged) != KEYS:
raise ValueError("missing required fields")
return merged, sources
with TemporaryDirectory() as directory:
path = Path(directory) / "app.prod.json"
path.write_text(json.dumps({"host":"127.0.0.1", "port":8000, "mode":"dev"}))
value, source = load(path, {"APP_PORT":"9001"}, ["--mode", "prod"])
assert value == {"host":"127.0.0.1", "port":9001, "mode":"prod"}
assert source == {"host":"file", "port":"env", "mode":"cli"}
print("GOOD", value, "via", source) # These synthetic fields are non-secret.
for env, argv in [({"APP_PORT":""}, []), ({"APP_PORT":"65536"}, []),
({"APP_PORT":True}, []), ({"APP_PORRT":"9001"}, []),
({}, ["--mode", ""]), ({}, ["--port", "-1"])]:
try:
load(path, env, argv)
except ValueError:
pass
else:
raise AssertionError("invalid config accepted")
for contents in [{"host":"127.0.0.1"}, {"host":"x","port":None,"mode":"dev"},
{"host":"x","port":8000,"mode":"dev","password":"placeholder"}]:
path.write_text(json.dumps(contents))
try:
load(path, {}, [])
except ValueError:
pass
else:
raise AssertionError("invalid file accepted")
try:
load(Path(directory) / "app-prod.json", {}, [])
except FileNotFoundError:
pass
else:
raise AssertionError("wrong path accepted")
print("PASS: precedence, present-empty rejection, types, unknown keys, range and missing path")
The negative cases reject a present-but-empty override instead of falling back, a port outside 1–65535, a Boolean masquerading as an integer, an unknown APP_ key, invalid command-line values, missing fields, nulls, and unknown file keys. The deliberately wrong filename raises file-not-found. Only the successful synthetic merge is printed, followed by PASS; real secret values should never be included in equivalent diagnostics. The fixture validates this loader’s declared policy, not process-environment wiring or every configuration format. Unknown-key rejection covers APP_ names; a misspelling outside that prefix can still be ignored. Names, precedence and empty-value handling are application contracts, rather than automatic consequences of using JSON or environment variables.
Further reading
- Environment variables are a per-process thing
- EnvironmentFile supplies values when systemd executes a process
Sources
- 12 Factor: config and environment, environment-driven configuration, checked 2026-10-15.
- The Twelve-Factor App: build, release, run, separating stages, checked 2026-10-15.
- man page: environment variables, how env shapes a process, checked 2026-10-15.