Have you ever watched an app boot without a traceback and still call the wrong host? I did, and I spent forty-eight hours blaming a config library I had not actually proven. The laptop looked honest because every getenv call returned a value my shell already trusted. Then the same entrypoint started on a clean server process and quietly used fallbacks I had typed as temporary.
This is a field note, not a benchmark and not a production war story with fake graphs. I am writing down what I tried, what broke, and what I would repeat. If you have ever said "but it works on my machine" about secrets, you already know the smell.
The background I should have questioned on hour zero
I asked a coding assistant for a small settings module and treated the green tests as proof. I used MonkeyCode's free model access to draft the loader, then ran the same tree on the free server option so the two environments could disagree in public.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The generated code was the usual helpful kind, which means it never crashed on a missing key. It always returned something, and that politeness is the first smell. I ignored it because the unit tests were green and the health route printed ok.
# settings.py — labeled example of the version I almost shipped
import os
def get_settings():
return {
"app_token": os.getenv("APP_TOKEN", "dev-token"),
"api_url": os.getenv("API_URL", "http://127.0.0.1:8000"),
"cache_ttl": int(os.getenv("CACHE_TTL", "60")),
}
Does that look broken to you at a glance, or does it look like every tutorial you have pasted? It did not look broken to me either, because my shell had been carrying the real values since earlier in the week. The function never told me whether a key was present. It only told me what Python would use after a silent fallback.
Hours 0–8: I trusted the parent environment
I had exported the real token in .bashrc months earlier and then forgotten the file existed. Pytest was collecting from the same interactive shell, so os.environ was already populated. The .env on disk could have been empty, and the tests would still pass with a smile.
Here is the command trail I actually ran, including the lines that lied to me politely.
# laptop, interactive shell — this is the trap
echo "APP_TOKEN is ${APP_TOKEN:+set}"
python -c "import os; print('APP_TOKEN' in os.environ)"
pytest -q tests/test_settings.py
# same laptop, now closer to a non-login service
env -i PATH="$PATH" HOME="$HOME" python -c "import os; print(os.getenv('APP_TOKEN'))"
That last line returned None, which should have ended the evening. I stared at it, then went back to the passing pytest output like a coward. Why would I distrust a green bar that had been green all afternoon? Because a test process is not your worker process, and I already knew that.
What I tried in that first block
- Printed
get_settings()from a REPL that had inherited my exports. - Added a health route that echoed
api_urlwithout saying whether it was a default. - Reran pytest after a tiny comment change, which proved nothing about boot.
- Blamed "the server being weird" before I blamed my own shell folklore.
Hours 8–24: dotenv loaded, and still nothing changed
I added python-dotenv because that is what every snippet suggests when a key is missing. I called load_dotenv() at the top of settings.py and committed .env.example like a responsible person. The clean server still started with dev-token and http://127.0.0.1:8000.
Two facts I had forgotten, both still true with current CPython and with python-dotenv's default behavior:
-
load_dotenv()does not override variables that already exist, unless you passoverride=True. - A process manager does not source
.bashrc. Neither doescron. Neither does most remote "run this file" buttons.
I also had .env in .gitignore, which is correct for secrets and fatal for a machine that has never seen your laptop shell. Was the import order the bug? Partly. A test helper imported get_settings before load_dotenv() ran, and Python cached the module. The server path had the opposite problem: nothing was in the environ, and the code defaults looked like success.
# labeled example: the second version, still wrong
from dotenv import load_dotenv
import os
load_dotenv() # too late if something already imported settings
# also too polite: it will not clobber a stale APP_TOKEN from the parent
Would override=True have saved me on the laptop? It might have, and that would have been another lie. Overriding would have made the file win locally, while the clean host still had no file at all. I needed a crash, not a different winner.
Hours 24–40: the child process made it worse
The worker was not the parent, which I should have treated as the entire story. I had a tiny supervisor that spawned the API with subprocess.Popen. The generated snippet passed env={} "to keep the child clean," which is a phrase that should scare you.
import subprocess
import sys
# labeled example: this replaces the entire environment
proc = subprocess.Popen(
[sys.executable, "-m", "app.worker"],
env={}, # PATH is gone, APP_TOKEN is gone, sanity is gone
)
On my laptop I never hit this branch because I started the worker with python -m app.worker in the same shell. On the free server I used the supervisor path, because that is what the README I had just generated told me to do. The child started. It inherited nothing. It used every default and stayed up.
If you want a smaller reproduction, this file is enough. Run it once, then ask which path your tests actually execute.
# repro_env_boundary.py — runnable on a stock CPython
import os
import subprocess
import sys
os.environ["APP_TOKEN"] = "from-parent"
code = "import os; print(repr(os.getenv('APP_TOKEN')))"
parent = subprocess.check_output([sys.executable, "-c", code], text=True)
empty = subprocess.check_output(
[sys.executable, "-c", code],
env={"PATH": os.environ.get("PATH", "")},
text=True,
)
print("inherited:", parent.strip())
print("minimal:", empty.strip())
I had been testing the parent path for two days. The worker lived on the minimal path. That mismatch is not exotic, and it is not a framework bug. It is a process boundary I refused to print.
The artifact I wish I had at hour one
I now refuse to discuss config until this audit script exits zero on the process I actually launch. It does not print secret values. It prints sources, which is the only part I needed.
# config_audit.py
"""Reproducible config source audit. Run on every host before you trust settings."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
REQUIRED = ("APP_TOKEN", "API_URL", "CACHE_TTL")
ENV_FILE = Path(".env")
LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
def parse_env_file(path: Path) -> dict[str, str]:
if not path.is_file():
return {}
found: dict[str, str] = {}
for raw in path.read_text(encoding="utf-8").splitlines():
if not raw or raw.lstrip().startswith("#"):
continue
match = LINE.match(raw)
if match:
found[match.group(1)] = match.group(2).strip().strip("'\"")
return found
def source_for(key: str, file_keys: dict[str, str]) -> str:
if key in os.environ:
return "process-environ"
if key in file_keys:
return "dotenv-file-not-applied"
return "code-default-or-missing"
def main() -> int:
file_keys = parse_env_file(ENV_FILE)
failed = False
print(f"cwd={Path.cwd()}")
print(f"pid={os.getpid()} dotenv_exists={ENV_FILE.is_file()}")
print(f"{'key':<12} {'source':<26} present_in_process")
for key in REQUIRED:
src = source_for(key, file_keys)
present = key in os.environ
print(f"{key:<12} {src:<26} {present}")
if src != "process-environ":
failed = True
if failed:
print("audit failed: a required key is not in the process environ", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Test plan I keep next to the script
- Run
python config_audit.pyin your interactive shell and read the source column, not the vibe. - Run
env -i PATH="$PATH" python config_audit.pyfrom the repo root and expect a fail unless the entrypoint applied the file. - Launch the audit with the same argv, user, and working directory as the worker, not as the parent.
- Fail the build if
get_settings()can return a hardcoded fallback for a required key. - Keep
.env.examplecommitted and.envuncommitted, then assert the host writes the real file before boot.
Decision table I now tape above the loader
- Interactive shell has the key, audit passes, worker still defaults → you tested the parent, not the child.
-
.envexists,load_dotenv()ran, values still missing → wrong cwd,exportprefixes, or a BOM on the first line. -
.envexists, values look stale →load_dotenv()refused to override the parent. That is default behavior. - Service starts under systemd, cron, or a panel →
.bashrcnever ran. UseEnvironmentFile=or load inside the entrypoint. - Subprocess called with
env={}→ you deleted PATH and every secret. Copyos.environ, then set keys.
What broke, in one list I can reuse
- Silent defaults turned a missing secret into a running process with a health check.
- Pytest inherited my laptop exports, so the suite never saw the empty-host case.
-
load_dotenv()ran after the first import, and the module cache kept the old mapping. - The supervisor replaced the child environment instead of extending it, on purpose.
- The clean server had no
.bashrcand no.env, which is the honest environment I should have tested first.
Would I have caught this with more logging? Only if the log line distinguished "set" from "defaulted." A print of api_url is how you hide the bug behind a successful boot. I do not want a prettier log. I want a nonzero exit.
What I would repeat, and what I would not
I would generate the loader with a model, then immediately run the audit under env -i. I would keep a clean host around as a second opinion, because it does not share my shell folklore. I would refuse defaults for required keys and let the process crash at boot, loudly.
I would not paste live tokens into a chat window, even when the prompt begs for a "complete example." I would not let a README start the worker with a cleaned env dict. I would not treat a green unit test as evidence that a service manager will see the same mapping tomorrow.
Who should not use this approach
Do not use silent defaults if you handle payments, private data, or anything that must fail closed. Do not put customer credentials on a shared free host, even for a quick demo. Do not ask a model to "just make it work" on config, because that prompt is how dev-token ships.
This audit does not replace a secret manager, a vault injector, or rotation. It only tells you whether the process you are actually running has the keys you think it has. If you need multi-tenant isolation, stop here and use those tools instead of a .env file and hope.
Limitations I hit anyway
The script does not understand YAML, TOML, or nested aliases from a settings library. It will not decrypt sops files, and it cannot see variables that exist only inside a container layer you did not exec into. If two keys differ only by case, you still have a human problem sitting on the keyboard.
Forty-eight hours is a long time to spend on os.environ, and I would rather spend twenty minutes on the audit and feel slightly paranoid. Paranoia is cheaper than a worker that is cheerfully calling 127.0.0.1 on a machine that is not your laptop. If you already want a laptop and a clean host to disagree on purpose, a free model plus a free server is a reasonable way to force that contrast. I would still print the source of every setting before I trust the boot logs.
Top comments (0)