Secrets and .env Files Per Environment in FastAPI

Key takeaways:

  • pydantic-settings resolves in a fixed order: init arguments, environment, .env, secrets directory, defaults.
  • Put non-sensitive per-environment values in a committed .env file; inject secrets as environment variables.
  • SecretStr masks the value in repr and str, so a stray log line prints asterisks.
  • Select the .env path from a bootstrap variable before constructing Settings.
  • Log the resolved settings at startup — masked — so misnamed keys fail loudly instead of silently defaulting.

This guide extends Configuration Management, which introduces settings as a typed object; here the focus is the layering between environments and what happens to the credentials in the middle of it.

The Problem This Solves

Staging needs a different database URL from production, and both need a password that must never appear in the repository, in a log line, or in a Sentry event. Meanwhile a developer running locally wants everything to work with no configuration at all.

Those three requirements pull in different directions, and the usual failure is to resolve the tension with os.environ.get("DATABASE_URL", "postgresql://localhost/app") scattered across the codebase. That gives you no type checking, no single place to see what the app needs, defaults that silently mask a missing variable in production, and a password that a logging.info(f"config: {config}") will happily print.

Settings source priority in pydantic-settingsFive stacked layers showing that arguments passed to Settings win over process environment variables, which win over the dotenv file, which wins over the secrets directory, which wins over the class field defaults.winsfalls back1 · Settings(database_url=...) arguments2 · process environment — injected secrets3 · .env file — per-environment values4 · secrets directory — mounted files5 · class defaults — local development

Why It Happens

BaseSettings is a normal Pydantic model with a customised initialisation step. Before validation runs, it asks each configured settings source for a dictionary of values and merges them, with earlier sources taking precedence. The default chain is, highest priority first: arguments passed to the constructor, then the process environment, then the dotenv file, then the secrets directory, then the model's own field defaults.

That ordering is the whole design. It means a .env file can carry the values that describe an environment while the orchestrator injects the values that must not be written down — and the injected ones win without anyone having to remember to delete a line from the file.

SettingsConfigDict configures those sources. env_prefix namespaces the environment lookups so your database_url reads APP_DATABASE_URL and cannot collide with something else on the host. env_file names the dotenv path. extra="ignore" stops an unrelated variable in the file from raising a validation error — worth setting, because a shared .env usually carries keys for more than one process.

SecretStr is a different mechanism. It is a wrapper type whose __repr__ and __str__ return a fixed mask instead of the value. Since almost every accidental disclosure goes through one of those two methods — an f-string in a log call, a repr() in a traceback frame, a settings object dumped into an error report — wrapping the field converts a class of accidents into a harmless string of asterisks. Reading the real value requires calling get_secret_value(), which is greppable and reviewable.

The Fix

Build the settings class once, choose the dotenv path from a bootstrap variable, and wrap every credential in SecretStr. This example creates the .env file it reads so the transcript below is produced by genuinely resolving all three layers:

"""Resolve settings through three layers — class defaults, .env file, process environment."""
import os
import tempfile
from pathlib import Path

from fastapi import FastAPI
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

ENV_DIR = Path(tempfile.mkdtemp(prefix="cfg-layered-"))
ENV_FILE = ENV_DIR / ".env.staging"
ENV_FILE.write_text(
    "APP_ENVIRONMENT=staging\n"
    "APP_DATABASE_URL=postgresql://app@db.staging.internal/app\n"
    "APP_DATABASE_PASSWORD=env-file-password\n"
    "APP_REQUEST_TIMEOUT_S=8.0\n"
)

# The orchestrator's injected secret. It must win over the .env file.
os.environ["APP_DATABASE_PASSWORD"] = "injected-by-the-orchestrator"


class Settings(BaseSettings):
    """Layer 1 is these defaults; the .env file overrides them; the environment overrides that."""

    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_file=ENV_FILE,
        env_file_encoding="utf-8",
        extra="ignore",
        frozen=True,
    )

    environment: str = "local"
    database_url: str = "postgresql://app@localhost/app"
    database_password: SecretStr = SecretStr("change-me")
    request_timeout_s: float = 3.0
    log_level: str = Field(default="INFO", description="Never set to DEBUG in production.")


settings = Settings()

In a real service the .env path comes from a bootstrap variable rather than a temporary directory, and it is the one value you read straight from the environment:

# Choose the file BEFORE the class is defined — the class cannot know its own environment.
ENV_NAME = os.environ.get("APP_ENV", "local")
ENV_FILE = Path(__file__).parent / f".env.{ENV_NAME}"

frozen=True is worth the two seconds it costs. Settings that can be mutated at runtime turn "what was the timeout in production at 04:00" into an unanswerable question.

Here is the resolved configuration and the leak check, from a real run:

$ GET /config
200 OK
{
  "environment": "staging",
  "database_url": "postgresql://app@db.staging.internal/app",
  "request_timeout_s": 8.0,
  "log_level": "INFO",
  "database_password": "**********",
  "resolved_from": {
    "environment": "the .env file (default was 'local')",
    "database_password": "the process environment (the .env value lost)",
    "log_level": "the class default (nothing else set it)"
  }
}

$ GET /config/leak-check
200 OK
{
  "repr_of_settings": "Settings(environment='staging', database_url='postgresql://app@db.staging.internal/app', database_password=SecretStr('**********'), request_timeout_s=8.0, log_level='INFO')",
  "repr_of_secret": "SecretStr('**********')",
  "str_of_secret": "**********",
  "explicit_reveal": "injected-by-the-orchestrator"
}

All three layers are visible in that first response. environment and database_url came from the .env file, overriding the class defaults. request_timeout_s came from the file as the string "8.0" and arrived as the float 8.0, because settings sources return strings and Pydantic coerces them against the annotation. log_level fell all the way through to its default because nothing set it. And database_password shows injected-by-the-orchestrator only when get_secret_value() is called explicitly — the .env file's env-file-password lost, exactly as the priority order promises.

The second response is the part to internalise. repr() of the entire settings object — the single most common way a configuration leaks into a log aggregator — prints the database URL in full and the password as ten asterisks.

Verification

Two checks belong in CI, and one belongs at startup.

In CI, assert the precedence rather than assuming it, especially after a pydantic-settings upgrade:

def test_environment_beats_env_file(monkeypatch, tmp_path):
    env_file = tmp_path / ".env"
    env_file.write_text("APP_DATABASE_PASSWORD=from-file\n")
    monkeypatch.setenv("APP_DATABASE_PASSWORD", "from-environment")
    s = Settings(_env_file=env_file)
    assert s.database_password.get_secret_value() == "from-environment"

Also assert that production cannot boot with a placeholder, which turns a missing injected secret from a runtime incident into a failed deploy:

@model_validator(mode="after")
def reject_placeholder_secrets(self) -> "Settings":
    if self.environment == "production" and self.database_password.get_secret_value() == "change-me":
        raise ValueError("APP_DATABASE_PASSWORD was not injected in production")
    return self

At startup, log the resolved settings once. Because credentials are SecretStr, logger.info("settings=%r", settings) is safe, and it gives you a line in your log aggregator that answers "what configuration was this pod actually running" without a shell into the container. If you already emit structured logs, Structured JSON Logging with Request IDs covers the format.

Trade-offs and When Not To

A .env file is not a secrets manager. It has no rotation, no audit trail, no per-consumer access control, and it sits on disk in the container image or on a mounted volume. For anything with real blast radius, fetch from Vault, AWS Secrets Manager or your platform's equivalent at startup and pass the results into Settings(...) as constructor arguments — the highest-priority source, which is precisely why that source exists.

Secrets fetched at startup do not rotate. A settings object built once at import time holds whatever was valid at boot. If your credentials rotate on a schedule shorter than your deploy cadence, you need a refreshable client rather than a frozen settings field, and the refresh belongs in the lifespan — see Lifespan Events vs Startup and Shutdown.

SecretStr is a guardrail, not a control. It stops accidents. It does not stop get_secret_value() appearing in a debug print, does not encrypt memory, and does not survive model_dump() unless you check — dumping a model containing a SecretStr yields the SecretStr object in Python mode and raises a serializer warning in JSON mode rather than silently emitting the value.

Defaults are dangerous in production. A default is the right call for a local development port. It is the wrong call for a database URL, because a missing variable then produces a working app pointed somewhere unexpected instead of a loud failure. Leave production-critical fields required and let the app refuse to start.

For a comparison of pydantic-settings against the alternatives, see Pydantic Settings vs Dynaconf vs python-decouple.

FAQ

What order does pydantic-settings resolve sources in? Highest priority first: arguments passed to Settings(), then process environment variables, then the .env file, then the secrets directory, then the field defaults on the class. A real environment variable therefore always beats the same key in a .env file.

Should I commit .env files to the repository? Commit a .env.example listing every key with placeholder values, and commit non-sensitive per-environment files if it helps. Never commit a file containing real credentials — secrets should arrive as injected environment variables or through a secrets directory mounted by the orchestrator.

Does SecretStr actually stop a secret from being logged? It masks the value in repr and str, which covers accidental logging of the settings object, f-strings and tracebacks. It does not encrypt anything and does not stop code that calls get_secret_value(), so it is a guardrail against accidents rather than a security control.

How do I load a different .env file per environment? Choose the path before constructing Settings, usually from a bootstrap variable such as APP_ENV read directly from os.environ, and pass it as env_file. Loading the file inside the class means the class has to know which environment it is in before it has been configured.

Why is my environment variable being ignored? Almost always a prefix mismatch. With env_prefix set to APP_ the field database_url reads APP_DATABASE_URL, not DATABASE_URL. Print the resolved settings at startup — with secrets masked — so a misnamed variable shows up immediately.