.env File Management

A .env file is the most convenient way to run an app locally and the most common way teams leak credentials into git. The danger is twofold: the file overwrites real platform secrets when loaded carelessly, and it ends up committed. This page makes .env loading safe in both directions.

Within the configuration pipeline, the .env file is a development-only source that must sit below the real environment variables in the precedence order — it fills gaps locally, never overrides production.

Secure implementation

# config/dotenv_loader.py
import os
from pathlib import Path
from dotenv import dotenv_values

def load_env(path: str = ".env") -> None:
    env_path = Path(path)
    if not env_path.exists():
        return                                  # production has no .env; that's fine
    file_values = dotenv_values(env_path)       # isolated dict, not a mutation
    for key, value in file_values.items():
        if value is not None and key not in os.environ:  # override=False semantics
            os.environ[key] = value             # only fill genuinely missing keys

By reading into a dict and writing back only the missing keys, a platform-injected DATABASE_URL is never clobbered by a stale local one. This is override=False implemented explicitly.

Configuration reference

Option Type Default Security implication
dotenv_values(path) dict Isolated; no os.environ mutation
load_dotenv(override=...) bool False True overwrites real injected secrets — avoid
.env in .gitignore n/a Prevents the most common secret leak
.env.example file Documents required keys without real values

Deployment parity: local to production

  1. Local dev — developer keeps an uncommitted .env; load_env() fills missing keys only.
  2. CI — no .env file exists; required keys come from pipeline variables and the secret store.
  3. Staging/Production — the orchestrator injects real values; load_env() is a no-op because the file is absent and keys are already set.

The same code path runs everywhere; only the presence of the file differs.

Security boundaries & guardrails

  • .env is always in .gitignore; a committed .env.example carries dummy values only.
  • Run gitleaks or detect-secrets as a pre-commit hook to block accidental commits.
  • Set file permissions to 600 so other local users cannot read it.
  • Keep override=False; never let a local file win over an injected secret.
  • Wrap any secret read from .env in SecretStr inside the settings model.

Troubleshooting

  • Production secret overwritten by .env — you called load_dotenv(override=True); switch to the isolated-merge pattern above.
  • .env accidentally committed — rotate every credential it contained immediately, then purge it from history and add the pre-commit scanner.
  • Variable not picked up — the key already exists in os.environ; with override=False that is intentional, the existing value wins.
  • SyntaxWarning on load in Python 3.12 — unescaped backslashes in values; see Safely Load .env Files in Python 3.12.

Frequently asked questions

What is the difference between load_dotenv and dotenv_values?

load_dotenv mutates os.environ in place, which can overwrite platform-injected values. dotenv_values returns an isolated dict you can merge deliberately — safer in containers where the orchestrator already set real values.

Should override be True or False when loading a .env file?

override=False (the default) is correct for production parity — a value already present in the environment, such as a Kubernetes secret, must win over the local .env. Use override=True only in isolated local testing.

How do I stop a .env file from being committed?

Add .env to .gitignore, commit a .env.example with dummy values instead, and run a secret scanner as a pre-commit hook so a leak is blocked before it reaches history.

Conclusion

The invariant: a .env file may fill missing local values but may never override an injected one, and it never enters version control. Load it into an isolated dict, merge with override=False semantics, and gate it with gitignore plus a pre-commit scanner.