Framework Configuration Integration
A Django project boots fine in CI and returns a 500 on its first real request in production, because a template setting was read lazily and the variable behind it was never set. The failure is not in the configuration values — it is in where the framework reads them and when. Every Python web framework has an integration point for configuration, each with a different moment of truth, and matching your validated settings model to that moment is what turns a class of production incidents into a boot-time error. This page covers Django, Flask, FastAPI and Celery, and sits under core configuration patterns as the layer where those patterns meet a real application.
The failure mode is worth naming precisely, because it recurs in every framework. Configuration read eagerly — at import, at startup, in an application factory — fails at a moment when a human is watching: a deploy, a container start, a CI job. Configuration read lazily — on first use, inside a view, on the first task — fails at a moment when a user is watching. The same missing variable produces a clean rollback in the first case and a paged engineer in the second. Almost everything on this page is an application of that one rule to a specific framework’s lifecycle.
Where the settings model sits in the application
The architectural rule is that the framework never owns the schema. A single module — conventionally config.py at the project root — defines one BaseSettings subclass, constructs it once, and exports the instance. Everything else is an adapter: Django’s settings.py assigns from it, Flask’s factory copies it into app.config, FastAPI injects it through a dependency, Celery reads it when the worker boots. That inversion matters because frameworks disagree about almost everything else. Django wants module-level uppercase names; Flask wants a dictionary of uppercase keys; FastAPI wants a callable it can inject; Celery wants a namespace it can map onto its own option names. If each framework owned its configuration you would have four schemas that drift apart. With one model and four adapters you have one place to add a field, one place to validate it, and one artefact that CI can construct to prove an environment is complete.
This positioning also decides where precedence is resolved: inside the model, before any framework sees a value. By the time settings.database_url exists, the question of whether it came from the process environment, a .env file, or a secret store has already been settled by the model’s source ordering. The adapter layer is deliberately dumb — it copies already-resolved values into whatever shape the framework expects and does no merging of its own. A Flask app.config.from_envvar call or a Django os.environ.get fallback reintroduces a second precedence system that the model does not know about, and the two will eventually disagree in a way that is very hard to debug.
There is a second reason to keep the model framework-agnostic, and it shows up the first time you write a management command, a data-migration script, or a one-off job. None of those have an application object. If configuration lives in app.config, every script needs to construct an app to read a database URL; if it lives in a plain model, the script imports config and is done. The same applies to tests, which is why the testing patterns for configuration all assume the model can be built in isolation.
Secure implementation
One model, constructed once, adapted four ways. The model itself carries the security decisions; the adapters carry none.
# config.py — the single schema every process shares
from functools import lru_cache
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", # local convenience only; never shipped
extra="forbid", # a typo'd variable is an error, not a silent no-op
case_sensitive=False,
)
environment: str = "local"
secret_key: SecretStr # masked in repr, logs and tracebacks
database_url: PostgresDsn # parsed and validated, not just a string
redis_url: str = "redis://localhost:6379/0"
allowed_hosts: list[str] = ["localhost"]
debug: bool = False
@lru_cache
def get_settings() -> Settings:
"""One validated instance per process, built on first call."""
return Settings()
settings = get_settings() # constructed at import: a bad env fails the boot
The lru_cache is doing real work here rather than micro-optimising. Constructing a BaseSettings model re-reads the .env file, re-runs every validator and re-parses every typed field, so a framework that calls the factory per request would pay that cost per request — and, worse, could observe different values within a single process if the environment changed underneath it. Caching makes the model a process-level singleton with a single moment of construction, which is exactly the property that lets the module-level settings = get_settings() line act as a boot-time gate.
# settings.py — Django reads module-level names, so assign from the model
from config import settings
SECRET_KEY = settings.secret_key.get_secret_value() # Django needs the raw str
DEBUG = settings.debug
ALLOWED_HOSTS = settings.allowed_hosts
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": settings.database_url.path.lstrip("/"),
"USER": settings.database_url.username,
"PASSWORD": settings.database_url.password,
"HOST": settings.database_url.host,
"PORT": settings.database_url.port or 5432,
}
}
# app.py — Flask copies resolved values into app.config inside the factory
from flask import Flask
from config import settings
def create_app() -> Flask:
app = Flask(__name__)
app.config.update( # uppercase keys only — Flask ignores others
SECRET_KEY=settings.secret_key.get_secret_value(),
SQLALCHEMY_DATABASE_URI=str(settings.database_url),
DEBUG=settings.debug,
)
# extensions read app.config at init time, so configure BEFORE init_app
from extensions import db
db.init_app(app)
return app
# main.py — FastAPI injects the cached model as a dependency
from typing import Annotated
from fastapi import Depends, FastAPI
from config import Settings, get_settings
app = FastAPI()
SettingsDep = Annotated[Settings, Depends(get_settings)]
@app.get("/healthz")
def healthz(config: SettingsDep) -> dict[str, str]:
# get_settings is cached, so this resolves an existing object, not a new one
return {"environment": config.environment}
Note what the Django adapter does with SecretStr: it calls get_secret_value() at exactly one line, the line that hands the value to the framework that requires a plain string. Everywhere else in the codebase the secret stays wrapped, so an accidental print(settings) or a captured traceback shows SecretStr('**********') rather than the key. That single-unwrap-point discipline is the practical form of the redaction rules, and it is easy to enforce in review because there is exactly one place to look.
Integration reference
Each framework’s integration point, when it is read, and the guardrail that keeps it honest.
| Framework | Integration point | Read at | Typical failure if wrong | Guardrail |
|---|---|---|---|---|
| Django | module-level names in settings.py |
first import of the settings module | ImproperlyConfigured or a lazy 500 on first request |
assign from the model; never os.environ.get inline |
| Flask | app.config (uppercase keys) |
inside the application factory | extension silently uses its default | update app.config before any init_app |
| FastAPI | Depends(get_settings) |
per request, cached to once | model rebuilt on every request | @lru_cache on the factory |
| Celery | app.conf / worker process env |
worker boot | task fails only in production | give the worker the same environment as the web process |
| Management commands | plain from config import settings |
script import | script needs a whole app to read a URL | keep the model framework-free |
Read the table as a list of moments rather than a list of APIs. The integration point is trivia you can look up; the “read at” column is the thing that determines whether a mistake costs a rolled-back deploy or a postmortem, and it is the column to check first when adding any new framework or extension to the stack. If a component reads configuration later than the framework’s own startup, wrap it so it reads earlier.
The last row is the one teams add late and wish they had added first. A model that can be imported without a framework is usable from a migration script, a cron job, a notebook and a test, which means those contexts get the same validation as the web process instead of their own ad-hoc os.environ reads.
or fallback, precedence has quietly forked.Deployment parity, step by step
- Local. Developers run with a
.envfile that the model loads throughenv_file. Nothing else reads it — no framework-specific dotenv loader, no shell sourcing — so the local precedence is identical to production’s minus one source. - Test. Tests construct the model directly with overridden values rather than booting the framework, so a configuration test does not need a database or an app object. Framework adapters are exercised by one smoke test each, not by every test.
- CI. A job imports
configwith the target environment’s variables injected. Because the module-levelsettings = get_settings()line runs on import, a missing or malformed value fails the job with aValidationErrornaming the field. - Staging. The same container image runs with staging variables.
extra="forbid"means a leftover variable from an old release fails the boot rather than sitting unused and confusing the next person to read the environment. - Production. Identical image, production variables, secrets injected from a manager rather than a file. The only difference between staging and production is the values, never the loading mechanism.
- Every worker process. Repeat step 5 for the Celery worker, the beat scheduler and any cron container. Each is a separate process with a separate environment, and each must construct the same model at boot.
Step 6 is where most parity failures actually live. Teams verify the web process carefully and then define the worker as an afterthought — a second service block that inherits half the environment. The result is a task that works locally, passes CI (which usually runs the code in one process), and fails the first time it runs in production because the worker’s environment never had STRIPE_API_KEY. Making the worker construct the same model at boot converts that into a worker that refuses to start, which a deploy notices immediately.
One image, several processes
A modern deployment usually ships a single container image and runs it several times with different commands: gunicorn for the web tier, celery worker for background jobs, celery beat for the scheduler, and occasionally a one-shot manage.py migrate in a release step. Every one of those is a separate process, and every one of them imports config and constructs the model at boot. That uniformity is the point — the image contains one schema, and the only thing that varies between processes is which parts of the resolved configuration they actually use.
The subtlety is that they do not all need the same values, and pretending otherwise causes its own failures. A worker may need a Stripe key the web tier never touches; the web tier may need a session secret the worker never touches. If the model marks both as required, the process that does not need one still fails to boot without it, and you end up injecting credentials into processes that have no business holding them. Two approaches solve this cleanly. The simpler one gives such fields a safe default of None and validates their presence at the point of use — a task that requires the Stripe key asserts it on first call, which is late but explicit. The stricter one defines a small subclass per process role, sharing a common base for everything universal and adding required fields only where they belong, so each process validates exactly the surface it uses. The subclass approach costs a few lines and buys a genuinely accurate answer to “what does the worker need?”, which is a question that comes up in every credential-scoping review.
Whichever you choose, the release-step process deserves special attention. A migration container that runs with a stripped-down environment will fail on a required field it never uses, and the usual fix — adding a dummy value to the migration job — quietly weakens the schema for everyone, because now nothing proves the real value is present anywhere. Scope the requirement instead of faking the value.
Security boundaries and operational guardrails
- One unwrap point per secret.
get_secret_value()appears only in the adapter line that hands the value to the framework. Any other occurrence is a review finding. DEBUGis a validated field, never a string comparison.DEBUG = os.environ.get("DEBUG") == "1"in one file and!= "0"in another is how a production deploy ends up serving tracebacks.- Uppercase-only for Flask. Flask silently ignores lowercase keys in
app.config; a mis-cased key is not an error, it is a default. - Never
from django.conf import settingsinsideconfig.py. The dependency runs one way — the framework imports the model, never the reverse — or you get an import cycle that only appears under the production entry point. - Workers get the same secret delivery as the web process. If the web tier reads from a secret manager and the worker reads from a baked env var, rotation will break exactly one of them.
- Fail the boot, not the request. Any configuration read that happens inside a view, a task body, or a template tag should be moved to the model.
Two of these deserve a note on how they fail, because both fail quietly rather than loudly. Flask’s case rule is not a validation step: app.config.update(secret_key="…") succeeds, stores the key, and every extension that looks for SECRET_KEY continues using its own default. Nothing warns you, and the application runs — with session signing keyed on a value you did not choose. The equivalent trap in Django is the import cycle: config.py importing django.conf.settings works under manage.py (which sets up the settings module first) and fails under gunicorn (which imports the WSGI application first), so the bug appears only in the environment where you least want a novel import error. Both are avoided by the same discipline — the adapter depends on the model, never the reverse, and it copies into exactly the shape the framework documents.
The DEBUG guardrail is worth stating in stronger terms than a bullet allows. Because DEBUG controls whether tracebacks, SQL and settings values are rendered to an HTTP response, an accidental truthy value is a disclosure of the entire configuration surface, secrets included. Making it a typed boolean field on the model means the string "false" parses to False rather than to a truthy non-empty string, which is precisely the mistake a hand-rolled os.environ.get("DEBUG") makes. Adding a validator that refuses debug=True whenever environment == "production" closes the remaining gap: the deploy fails rather than the site leaking.
Troubleshooting
ImproperlyConfigured on first request, not at startup. Django resolves some settings lazily, so a value only read when a particular code path runs will not fail at boot. Fix it by asserting the field exists on the model — a required field with no default — so the model construction that happens at settings.py import fails immediately regardless of when Django would have touched it.
The .env file is ignored under gunicorn. The model reads env_file relative to the working directory. A process manager that starts the app from / finds no file, silently falls back to defaults, and boots with a local database URL. Use an absolute path derived from the module location, and treat the file as a local-only convenience rather than a deployment mechanism.
A Flask extension uses its default despite app.config being set. Extensions read configuration inside init_app. Any app.config.update after that line has no effect on the already-initialised extension. Move all configuration to the top of the factory.
FastAPI rebuilds settings on every request. The dependency factory is missing @lru_cache, so Depends calls it per request. Symptoms are a slow endpoint under load and file reads showing up in a profile. Adding the cache fixes both, and makes the dependency return the same object the module-level import already validated.
A Celery task raises KeyError in production only. The worker process has a different environment from the web process. Construct the settings model at worker boot — importing config in the Celery app module is enough — so the worker refuses to start rather than accepting tasks it cannot execute.
Two processes disagree about the same value. One process was restarted after a variable changed and the other was not, so they hold different snapshots of what is nominally the same configuration. This is not a bug in the model — a process-level singleton is exactly the right design — but it is a deployment property worth knowing: configuration changes take effect on restart, and a rolling deploy that restarts the web tier without the workers leaves the two tiers running different configurations for the duration. Either restart every process that imports the model, or accept the skew deliberately and make the fields involved tolerant of it. The diagnostic is to expose the resolved non-secret configuration on a health endpoint, so comparing two processes is a request rather than an archaeology exercise.
A useful general technique for all of these: add a startup log line that prints the field names the model resolved and the source each came from, never the values. It costs one line, it is safe to leave enabled in production, and it turns “which configuration is this process actually running?” from a guess into something you can read in the first ten lines of a container’s log. Pair it with the health endpoint above and most configuration debugging becomes a two-minute exercise instead of a bisect through deploy history.
Frequently asked questions
Should Django settings.py read os.environ directly?
No. Have settings.py import a single validated settings object and assign from it. Direct os.environ reads scattered through settings.py give you no schema, no types, and no single place to see what the project requires. They also encourage per-setting defaults — os.environ.get("TIMEOUT", "30") — which turn a missing production variable into a quietly wrong value rather than a failure. With a model, TIMEOUT is either a required field that fails the boot or a typed field with one visible default, and settings.py becomes a short list of assignments that anyone can read in a minute.
Why does FastAPI documentation cache the settings dependency?
Because a dependency function runs per request. Without an lru_cache the model is reconstructed on every request, re-reading files and re-running validators for a value that cannot change. The cache turns it into a one-time construction whose result is injected everywhere. It also makes the injected object identical to the one the module-level import validated at boot, so there is no window in which a request could observe a different configuration than the one the process started with — which matters as soon as you have a health check that reports the active environment.
Do Celery workers need their own configuration?
They need the same configuration, delivered the same way. A worker is a separate process with its own environment, so a variable exported only in the web container’s definition simply does not exist in the worker — the most common cause of a task that fails only in production. Import the shared config module in the Celery application module so the worker constructs and validates the model at boot. A worker that cannot start is a loud, immediate failure; a worker that starts and then fails one task type an hour later is an investigation.
Can two frameworks share one settings model in the same repository?
Yes, and a mixed repository is the case that makes the pattern pay for itself. A service that serves an API with FastAPI, runs an admin site on Django, and processes jobs with Celery has three entry points and one set of infrastructure credentials; without a shared model it has three partial copies of that set. Keep one model and one adapter per entry point, and resist adding framework-specific fields to the shared schema — anything only one framework needs belongs in a subclass owned by that entry point. The test for whether the split is right is whether a new database credential requires a change in one file or three. If it is three, the schema has leaked into the adapters and it is time to move fields back into the shared base.
Where should the settings model be constructed in a Flask app?
Inside the application factory, before any extension is initialised, and pushed into app.config as uppercase keys. Extensions read app.config at init time, so a value assigned after init_app is silently ignored. The practical shape is: create the app, app.config.update(...) from the model, then import and initialise extensions. Because the model is already constructed at import of config, the factory does no validation itself — it copies resolved values, which keeps the factory testable without an environment.
Key takeaways
The invariant this topic enforces is that configuration has one schema and one moment of truth per process. The model owns sources, precedence, types, validation and masking; each framework gets a thin adapter that renames already-resolved values into the shape it expects and does nothing else. That split is what stops four frameworks from growing four subtly different ideas of what DEBUG means, and it is what makes a management command, a test and a web request all see the same validated object.
The operational payoff is timing. Every adapter on this page is arranged so the read happens as early as the framework allows — at settings import for Django, in the factory for Flask, in a cached dependency for FastAPI, at worker boot for Celery — which converts missing and malformed configuration into a failed boot that a deploy can roll back. Combine that with extra="forbid" so stale variables surface, SecretStr with a single unwrap point so secrets stay out of logs, and a worker environment defined identically to the web one, and the whole category of “it only breaks in production” configuration incidents stops occurring.