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: the deployed values become explicit, reviewable, and reproducible just like code. When the file lives with the project, a change gets history, a blame, and a pull request, so anyone can answer ‘what actually shipped and who changed it’. 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 and the real values are encrypted where they live. If a value would be embarrassing or dangerous to publish, it does not belong in the committed file — that rule is exactly what tells you which are secrets.

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 uses only the standard library, writes two temporary JSON files, and never touches your real config. The good sample starts from {"host": "127.0.0.1", "port": 8000, "mode": "dev"}, applies APP_PORT=9001 from the environment, then --mode prod from the command line, and asserts both the merged result and the per-key source map.

"""Config precedence probe: file < environment < CLI (stdlib only)."""
import json
import os
import sys
import tempfile

REQUIRED = ("host", "port", "mode")
ENV_MAP = {"host": "APP_HOST", "port": "APP_PORT", "mode": "APP_MODE"}

def parse_cli(argv):
    """Support --host/--port/--mode and --set key=value; returns dict."""
    out = {}
    i = 0
    while i < len(argv):
        arg = argv[i]
        if arg == "--set" and i + 1 < len(argv):
            i += 1
            kv = argv[i]
            if "=" not in kv:
                raise ValueError(f"bad --set (want key=value): {kv!r}")
            k, v = kv.split("=", 1)
            out[k.strip()] = v
        elif arg in ("--host", "--port", "--mode") and i + 1 < len(argv):
            i += 1
            out[arg[2:]] = argv[i]
        elif arg.startswith("--") and "=" in arg:
            k, v = arg[2:].split("=", 1)
            if k in REQUIRED:
                out[k] = v
            else:
                raise ValueError(f"unknown flag: {arg!r}")
        else:
            raise ValueError(f"unknown argument: {arg!r}")
        i += 1
    return out

def coerce(key, value):
    if key == "port":
        return int(str(value).strip())
    return str(value).strip()

def load_config(path, env, cli_args):
    """Merge file < env < CLI; raise KeyError listing missing fields."""
    with open(path, encoding="utf-8") as fh:
        file_cfg = json.load(fh)
    merged, source = {}, {}
    for key in REQUIRED:
        if key in file_cfg and str(file_cfg[key]).strip() != "":
            merged[key] = coerce(key, file_cfg[key])
            source[key] = "file"
    for key in REQUIRED:
        name = ENV_MAP[key]
        if name in env and str(env[name]).strip() != "":
            merged[key] = coerce(key, env[name])
            source[key] = "env"
    for key, raw in parse_cli(cli_args).items():
        if key in REQUIRED and str(raw).strip() != "":
            merged[key] = coerce(key, raw)
            source[key] = "cli"
    missing = [k for k in REQUIRED if k not in merged]
    if missing:
        raise KeyError(f"missing required fields: {', '.join(missing)}")
    return merged, source

def main():
    with tempfile.TemporaryDirectory() as tmp:
        good = os.path.join(tmp, "app.prod.json")
        with open(good, "w", encoding="utf-8") as fh:
            json.dump({"host": "127.0.0.1", "port": 8000, "mode": "dev"}, fh)
        # GOOD: env beats file, CLI beats env.
        merged, source = load_config(good, {"APP_PORT": "9001"}, ["--mode", "prod"])
        assert merged == {"host": "127.0.0.1", "port": 9001, "mode": "prod"}, merged
        assert source == {"host": "file", "port": "env", "mode": "cli"}, source
        print(f"GOOD {merged} via {source}")
        # BAD 1: missing field is an error, not a silent default.
        bad = os.path.join(tmp, "app.empty.json")
        with open(bad, "w", encoding="utf-8") as fh:
            json.dump({"host": "127.0.0.1"}, fh)
        try:
            load_config(bad, {}, [])
        except KeyError as exc:
            print(f"BAD-MISSING correctly rejected: {exc}")
        else:
            raise AssertionError("empty config should have failed")
        # BAD 2: drifted filename must fail closed, never fall back silently.
        try:
            load_config(os.path.join(tmp, "app-prod.json"), {}, [])
        except FileNotFoundError as exc:
            print(f"BAD-DRIFT correctly failed closed: {exc.filename}")
        else:
            raise AssertionError("drifted filename should have failed")
    print("PASS: file < env < cli, missing fields error, drift fails closed")

if __name__ == "__main__":
    try:
        main()
    except (KeyError, ValueError, FileNotFoundError, AssertionError) as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        raise SystemExit(1)

The bad samples prove the guardrails. An almost-empty file is rejected with missing required fields for port and mode instead of running on silent defaults, and a drifted filename such as app-prod.json raises file-not-found instead of falling back to another file. Expect three lines starting with good, bad-missing, and bad-drift, followed by a pass line confirming file beats nothing, environment beats file, and flags beat environment. If the good merge differs, the precedence order is wrong; if either bad sample passes silently, the loader is too forgiving.

Further reading

Sources