Hello. In the previous lesson, you built a typed FastAPI endpoint: invalid JSON was rejected at the HTTP boundary, and successful responses were limited to an explicit schema. This lesson applies the same idea to values that must not live in request bodies or source files: API keys, deployment environment names, model choices, and operational limits.
A production AI service should run from the same codebase in development, staging, and production. What changes is its runtime configuration. By the end of this lesson, you will have a validated settings module that loads local configuration from an ignored .env file, accepts production overrides from real environment variables, and prevents secrets from being accidentally committed or logged.
Configuration is another input boundary
Hard-coding configuration often begins innocently:
OPENAI_API_KEY = "sk-real-key-goes-here"
MODEL_NAME = "gpt-4.1-mini"
REQUEST_TIMEOUT_SECONDS = 30
But this couples a repository to one environment. It also makes an accidental commit, a copied screenshot, or a shared archive a potential credential leak.
At the other extreme, retrieving every value manually with os.getenv() moves configuration out of source control but creates a different problem: environment variables arrive as strings. The application must repeatedly convert and validate them.

For example:
import os
max_connections = os.getenv("MAX_CONNECTIONS") # "20", not int 20
debug = bool(os.getenv("DEBUG")) # bool("False") is True
That second line is a common and serious bug. In Python, any non-empty string is truthy, including "False" and "0". A settings model centralizes this conversion once, validates it at startup, and gives the rest of the application correctly typed values.
Watch the following overview before implementing the pattern. It uses the same Pydantic model concepts you used for request and response schemas, but applies them to process configuration.
pydantic-settings - Modern, Type-Safe Configuration for Python Apps
Watch “pydantic-settings - Modern, Type-Safe Configuration for Python Apps” by BugBytes for a concise walkthrough of typed settings, required secrets, and dotenv loading.
Watch the motivation for keeping credentials and configuration outside application code. Then watch typed settings, focusing on how BaseSettings uses field annotations, defaults, and validation. Finish with dotenv loading to see how a local .env file supplies development values.
Define one typed settings model
Install the settings package in the virtual environment that contains your FastAPI project:
python -m pip install pydantic-settings
Create config.py beside the main.py from the previous lesson:
from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="SUPPORT_",
extra="forbid",
)
environment: Literal["development", "staging", "production"] = "development"
app_name: str = "Support Answer API"
model_name: str = "gpt-4.1-mini"
request_timeout_seconds: int = Field(default=30, ge=1, le=120)
max_context_tokens: int = Field(default=12_000, ge=256)
provider_api_key: SecretStr
@lru_cache
def get_settings() -> Settings:
return Settings()
This is not a model for client JSON. It is a model for the service’s runtime contract.
| Field | Type and constraint | Why it matters |
|---|---|---|
environment | One of three allowed deployment names | Prevents arbitrary or misspelled environment labels |
model_name | String with a safe development default | Lets each deployment select an appropriate model |
request_timeout_seconds | Integer from 1 to 120 | Rejects invalid values such as "fast" or -5 |
max_context_tokens | Bounded integer | Prevents a configuration typo from creating an unreasonable request budget |
provider_api_key | Required SecretStr | Refuses to start without a provider credential and masks it in ordinary representations |
Because the class inherits from BaseSettings, Pydantic seeks values for fields that were not passed directly to Settings(...). It reads matching environment variables, converts them to the annotated types, and validates constraints.
The prefix makes the configuration namespace explicit. For instance:
SUPPORT_MODEL_NAMEsuppliesmodel_name.SUPPORT_REQUEST_TIMEOUT_SECONDSsuppliesrequest_timeout_seconds.SUPPORT_PROVIDER_API_KEYsuppliesprovider_api_key.
A prefix prevents broad names such as MODEL_NAME or ENVIRONMENT from colliding with another service or a developer’s machine-level variables.
Read the opening portion of Pydantic’s official settings documentation for the underlying behavior, then its sections on dotenv files and source precedence.
Settings Management | Pydantic Docs
Read Pydantic’s official “Settings Management” guide to confirm how BaseSettings reads environment variables, why dotenv files are useful locally, and which source wins when values conflict.
In the opening introduction, read how BaseSettings loads values. Then find the “Dotenv (.env) support” section and read dotenv behavior. Finally, read the full “Field value priority” section, beginning with precedence order, and compare it with the deployment approach below.
Required values should fail early
provider_api_key has no default. Therefore, this fails clearly:
Settings()
if no corresponding environment variable, dotenv value, or initialization argument exists. That is desirable. A missing provider credential is a deployment error, not something that should surface later as a mysterious model-client failure.
Conversely, defaults are appropriate for non-secret, safe fallback values. A local developer can use the default request timeout, while staging can set a different timeout without changing Python code.
SecretStr reduces accidental disclosure when a settings object is printed or included in an exception representation. It is not encryption and it does not make a secret safe to log deliberately. Only unwrap it at the small boundary where a provider client genuinely needs the raw key:
settings = get_settings()
api_key_for_provider_client = (
settings.provider_api_key.get_secret_value()
)
Do not return this value from an endpoint, include it in structured logs, or print the full settings object while debugging.
Keep the local dotenv file out of Git
A .env file is a development convenience. It lets a developer run the service without modifying their shell profile or putting secrets into code.
Create a local .env file:
SUPPORT_ENVIRONMENT=development
SUPPORT_APP_NAME="Support Answer API"
SUPPORT_MODEL_NAME=gpt-4.1-mini
SUPPORT_REQUEST_TIMEOUT_SECONDS=30
SUPPORT_MAX_CONTEXT_TOKENS=12000
SUPPORT_PROVIDER_API_KEY=replace-with-your-local-provider-key
The value after SUPPORT_PROVIDER_API_KEY= must be a real credential on your own machine before a future provider integration can work. This file is private machine configuration, not project documentation.
Create a trackable .env.example alongside it:
SUPPORT_ENVIRONMENT=development
SUPPORT_APP_NAME="Support Answer API"
SUPPORT_MODEL_NAME=gpt-4.1-mini
SUPPORT_REQUEST_TIMEOUT_SECONDS=30
SUPPORT_MAX_CONTEXT_TOKENS=12000
SUPPORT_PROVIDER_API_KEY=replace-with-your-local-provider-key
The example file teaches a new teammate which keys are required, their expected spelling, and reasonable non-secret defaults. It must contain no valid credential.
Now add a .gitignore file, or extend the existing one:
# Local runtime configuration and credentials
.env
.env.*
!.env.example
The final line is important: it re-includes the safe template after .env.* ignores variant dotenv files such as .env.staging.
A practical project layout is:
support-answer-api/
├── .env # local only, ignored
├── .env.example # safe template, committed
├── .gitignore
├── config.py
└── main.py
A .gitignore rule prevents new, untracked files from being added. It does not erase a secret that Git already tracks. If you previously committed .env, remove it from future tracking:
git rm --cached .env
git add .gitignore .env.example
git commit -m "Stop tracking local environment configuration"
Then revoke or rotate every credential that appeared in the committed file. Treat a committed credential as exposed even if the repository is private: Git history, CI logs, forks, local clones, and backups can all preserve it.
Load once, validate at startup, inject where needed
The @lru_cache decorator ensures that get_settings() creates one validated settings object per Python process. Without it, a dependency that calls Settings() could reread .env and reconstruct the model for every request.
Use a FastAPI lifespan function to validate configuration when the server starts rather than waiting for the first request:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from config import get_settings
@asynccontextmanager
async def lifespan(app: FastAPI):
get_settings() # fail startup if required config is missing or invalid
yield
app = FastAPI(
title="Support Answer API",
lifespan=lifespan,
)
Keep your existing POST /v1/answers endpoint. Add a non-sensitive health response that demonstrates injecting settings through FastAPI:
from typing import Annotated, Literal
from fastapi import Depends
from pydantic import BaseModel
from config import Settings, get_settings
class HealthResponse(BaseModel):
status: Literal["ok"]
environment: str
@app.get("/health")
async def health(
settings: Annotated[Settings, Depends(get_settings)],
) -> HealthResponse:
return HealthResponse(
status="ok",
environment=settings.environment,
)
The route may safely report the deployment label, but it must not return the API key, database URL, internal provider endpoint, or an entire settings dump. Configuration contains both ordinary operational values and secrets; public response models should expose only explicitly chosen values.
FastAPI’s documented dependency pattern uses this same cached factory approach. It also provides a clean seam for the isolated tests you will write in the next lesson.
Settings and Environment Variables - FastAPI
Read FastAPI’s official guide on using settings as a dependency. Focus on why a cached settings factory is useful and how dependency injection makes a controlled configuration override possible in tests.
Find the “Settings in a dependency” section. Read the dependency rationale, then follow the get_settings example through the “Settings and testing” subsection. For now, focus on the boundary: routes ask for settings through Depends(get_settings) rather than reading process variables themselves.
Understand precedence before debugging it
The same setting can be available from several places. Pydantic Settings resolves conflicts using a defined priority. In the common setup here, the practical order is:
- A value passed directly to
Settings(...) - A real operating-system environment variable
- A value in
.env - The Python field default
The official documentation additionally covers optional command-line and secrets-directory sources.
This policy supports a reliable deployment workflow:
- Local development:
.envsupplies personal development values. - Staging and production: the deployment platform injects environment variables or mounts secrets from its secret-management system.
- Tests: construct
Settings(...)with explicit test values, which take precedence over a developer’s real environment.
For example, this Unix-like shell command temporarily overrides only the timeout for one server process:
SUPPORT_REQUEST_TIMEOUT_SECONDS=45 uvicorn main:app --reload
In PowerShell:
$env:SUPPORT_REQUEST_TIMEOUT_SECONDS = "45"
uvicorn main:app --reload
Even though the shell supplies "45" as text, Pydantic gives your application an int equal to . Try an invalid value such as not-a-number; startup should stop with a validation error identifying request_timeout_seconds. This is configuration validation doing its job before the service accepts traffic.
A deployment platform should provide the actual secret at runtime, not copy a .env file into the container image and not bake credentials into image layers. Later, when you deploy on AWS, this same settings contract can receive secret values sourced from AWS Secrets Manager.
A deployment-ready configuration checklist
Before considering configuration complete, verify these conditions:
config.pycontains field names, types, validation rules, and safe defaults, but no secret values..envexists only on your local machine and is ignored by Git..env.exampleis committed and contains names and placeholders only.- Required secrets have no meaningful application default.
- The service validates settings at startup.
- Production values are supplied by the deployment environment or secret store.
- Endpoints and logs never serialize settings wholesale or reveal secrets.
- Real environment variables override
.env, allowing deploy-time configuration without code changes.
Key takeaways
Environment-specific configuration lets one AI service codebase operate safely across development, staging, and production.
pydantic-settingsturns environment strings into validated, typed Python configuration.- A
BaseSettingsmodel centralizes configuration names, defaults, constraints, and required secrets. SecretStrmasks ordinary representations, but secrets must still never be logged or returned.- Use a local
.envfile for development, ignore it with Git, and commit only a credential-free.env.example. - Real environment variables override dotenv values, which allows deployment systems to supply production configuration.
- A cached FastAPI dependency avoids repeated settings construction and gives tests a future override point.
- Validating settings at startup turns missing keys and malformed values into clear deployment failures.
Next, you will write isolated tests for this AI service, including mocking model-provider responses so tests remain fast, deterministic, and independent of real API credentials.
Can't find a good explanation? Sign up and we'll make it for you
Sign up