⚙️ Configuration Management
The same codebase has to run on your laptop with a throwaway SQLite file, in CI against an in-memory database, and in production against a hardened Postgres cluster — without editing a line of Python. Good configuration management is what makes that possible, and it's also your first line of defense for keeping secrets out of version control.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Read and write settings through the
app.configobject - Organize config classes with inheritance for dev, test, and production
- Load configuration from objects, environment variables, and
.envfiles - Keep secrets safe with instance folders and
.gitignore - Add type-safe settings (Pydantic) and startup validation that fails fast
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a full config hierarchy with .env loading and validation, wired into a factory.
In This Lesson
Why Configuration Matters
Configuration is every value your app needs that isn't code: the database URL, the secret key, mail server credentials, feature flags, page sizes. The guiding principle comes from the Twelve-Factor App methodology: strictly separate config from code, and store anything that varies between deployments in the environment.
💡 Analogy: Think of config as the control panel bolted to the outside of a machine. The machine (your code) is sealed and identical everywhere; you change how it behaves by turning dials on the panel — never by cracking it open and rewiring the internals.
Done well, configuration lets you deploy the exact same artifact to every environment, keeps passwords out of your Git history, and makes testing trivial. Done poorly, it's the source of leaked credentials and "works on my machine" bugs.
The app.config Object
Flask exposes configuration as app.config, a dictionary subclass with a few extra loading helpers. You can read and write it like any dict:
# Writing values
app.config["SECRET_KEY"] = "change-me"
app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI="sqlite:///:memory:")
# Reading values (use .get() with a default to avoid KeyError)
key = app.config["SECRET_KEY"]
page_size = app.config.get("ITEMS_PER_PAGE", 20)
A handful of built-in keys control Flask itself. The most important ones:
| Key | Purpose | Default |
|---|---|---|
SECRET_KEY | Signs session cookies and CSRF tokens — must be secret and random | None |
DEBUG | Enables the interactive debugger and reloader (never on in prod) | False |
TESTING | Propagates exceptions and tweaks behavior for tests | False |
SERVER_NAME | Host/port for subdomains and out-of-context URL building | None |
SESSION_COOKIE_SECURE | Only send the session cookie over HTTPS | False |
⚠️ SECRET_KEY is not optional
If SECRET_KEY is unset or predictable, an attacker can forge session cookies and log in as anyone. In production it must be a long, random value generated once and stored as a secret — e.g. python -c "import secrets; print(secrets.token_hex(32))".
Config Classes & Inheritance
The cleanest way to manage per-environment settings is a small hierarchy of classes: a Config base with shared defaults, and one subclass per environment that overrides just what differs. A dictionary maps names to classes so the factory can pick one.
# config.py
import os
from datetime import timedelta
class Config:
"""Shared defaults for every environment."""
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-change-me")
SQLALCHEMY_TRACK_MODIFICATIONS = False
REMEMBER_COOKIE_DURATION = timedelta(days=14)
ITEMS_PER_PAGE = 20
@staticmethod
def init_app(app):
"""Hook for environment-specific setup (logging, folders, ...)."""
pass
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DEV_DATABASE_URL", "sqlite:///dev.db"
)
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False # simplifies form tests
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
SESSION_COOKIE_SECURE = True
REMEMBER_COOKIE_SECURE = True
config = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
The factory loads a class by name with from_object, then runs its init_app hook:
# app/__init__.py
import os
from flask import Flask
from config import config
def create_app(config_name=None):
app = Flask(__name__)
config_name = config_name or os.environ.get("FLASK_CONFIG", "default")
app.config.from_object(config[config_name])
config[config_name].init_app(app) # environment-specific setup
# ... extensions, blueprints ...
return app
✅ Why inheritance shines here
Shared settings live in exactly one place (Config). Each environment states only its differences, so there's no copy-paste drift, and the code is versioned and reviewable like everything else.
Loading from the Environment
Flask can load config from several sources. The two you'll use most are from_object (classes, above) and pulling individual values from environment variables via os.environ.
| Method | Source | Typical use |
|---|---|---|
from_object("config.Prod") | A Python class/module | Per-environment defaults in code |
from_pyfile("config.py") | A Python file (often in the instance folder) | Machine-local overrides, uncommitted |
from_prefixed_env() | Env vars starting FLASK_ | Container/12-factor deployments |
os.environ.get(...) | A single env var | Individual secrets like DATABASE_URL |
python-dotenv for development
Typing export commands before every run is tedious. The python-dotenv package reads a local .env file into the environment automatically. Flask loads .env and .flaskenv on its own if python-dotenv is installed:
pip install python-dotenv
# .env — NEVER commit this file
FLASK_CONFIG=development
SECRET_KEY=a-long-random-value
DEV_DATABASE_URL=sqlite:///dev.db
MAIL_USERNAME=you@example.com
MAIL_PASSWORD=super-secret
Because the config classes already read from os.environ, those values flow straight through — no extra wiring. If you need to load .env explicitly (e.g. in a script), call it before creating the app:
from dotenv import load_dotenv
load_dotenv() # populate os.environ from .env
from app import create_app
app = create_app()
Secrets & Instance Folders
Secrets — API keys, database passwords, the secret key — must never be committed. Two mechanisms keep them out of your repo.
The instance folder
Flask supports an instance folder: a directory alongside your package that is meant to hold machine-specific, uncommitted config. Create the app with instance_relative_config=True and load an optional config.py from it:
app = Flask(__name__, instance_relative_config=True)
app.config.from_object(config["default"])
# silent=True -> no error if the file doesn't exist
app.config.from_pyfile("config.py", silent=True)
myapp/
├── app/ # your package (committed)
├── instance/
│ └── config.py # secrets & local overrides (git-ignored)
└── config.py # safe defaults (committed)
Lock it down with .gitignore
Belt and braces: ignore the secret files, and ship an example so teammates know what to fill in.
# .gitignore
.env
.flaskenv
instance/
*.pem
*.key
# .env.example — committed as a template
FLASK_CONFIG=development
SECRET_KEY=
DATABASE_URL=
MAIL_USERNAME=
MAIL_PASSWORD=
💡 For teams and production
Beyond .env, dedicated secret managers — AWS Secrets Manager, Google Cloud Secret Manager, Azure Key Vault, HashiCorp Vault, or Docker/Kubernetes secrets — provide access control, auditing, and rotation. Your app still reads the values as environment variables; the manager is what injects them.
Type-Safe Config with Pydantic
Plain app.config is an untyped dictionary — a missing value or a string where you expected an int fails deep inside a request. Pydantic Settings validates and coerces types at load time, so a bad config crashes immediately with a clear message.
pip install pydantic-settings
# settings.py (Pydantic v2 style)
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=True)
SECRET_KEY: str = Field(min_length=16)
DEBUG: bool = False
SQLALCHEMY_DATABASE_URI: str
MAIL_PORT: int = 587
MAIL_USE_TLS: bool = True
@field_validator("SQLALCHEMY_DATABASE_URI")
@classmethod
def known_scheme(cls, v: str) -> str:
if not v.startswith(("sqlite://", "postgresql://", "postgresql+psycopg://", "mysql://")):
raise ValueError("Unsupported database URL scheme")
return v
# app/__init__.py
from flask import Flask
from settings import Settings
def create_app():
app = Flask(__name__)
settings = Settings() # reads env / .env, validates types
app.config.update(settings.model_dump())
# ... extensions, blueprints ...
return app
✅ What you gain
- Type coercion:
MAIL_PORT="587"becomes the int587automatically. - Required fields: a missing
SQLALCHEMY_DATABASE_URIfails at startup, not at first query. - Custom validation: reject malformed database URLs before they reach SQLAlchemy.
- Editor autocomplete and type hints on every setting.
Validate at Startup
Even without Pydantic, a few lines in the factory catch the most dangerous misconfigurations before they cause damage. The principle is fail fast: a clear error at boot beats a mysterious 500 in production.
def validate_config(app):
"""Refuse to start with dangerous or missing settings."""
if not app.config.get("SQLALCHEMY_DATABASE_URI"):
raise RuntimeError("SQLALCHEMY_DATABASE_URI is required")
if not app.debug and not app.testing:
if app.config["SECRET_KEY"] in (None, "", "dev-key-change-me"):
raise RuntimeError("Refusing to run in production with a default SECRET_KEY")
if not app.config.get("SESSION_COOKIE_SECURE"):
app.logger.warning("SESSION_COOKIE_SECURE should be True in production")
def create_app(config_name=None):
app = Flask(__name__)
# ... load config ...
validate_config(app)
# ... extensions, blueprints ...
return app
What a caught error looks like
RuntimeError: Refusing to run in production with a default SECRET_KEY
That single guard has stopped countless accidental deploys with a placeholder key. Validation turns a silent security hole into a loud, obvious failure you fix in seconds.
Hands-on Exercise
🏋️ Build a Config Hierarchy
Objective: Create a working config system with env loading and validation.
Instructions:
- Write
config.pywithConfig,DevelopmentConfig,TestingConfig,ProductionConfig, and aconfigdict. - Have
SECRET_KEYandSQLALCHEMY_DATABASE_URIread fromos.environwith dev-friendly defaults (production URI: no default). - Create a
.env(git-ignored) and a committed.env.example. - In
create_app(), pick the config byFLASK_CONFIG, load it, and call avalidate_config()that raises if the database URI is missing. - Confirm
create_app("testing")yieldssqlite:///:memory:.
💡 Hint
Only ProductionConfig should omit a database default — you want it to fail loudly if DATABASE_URL isn't set. Testing should hard-code the in-memory URI so tests never touch a real file.
✅ Solution
# config.py
import os
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-change-me")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL", "sqlite:///dev.db")
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
SESSION_COOKIE_SECURE = True
config = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
# app/__init__.py
import os
from flask import Flask
from config import config
def create_app(config_name=None):
app = Flask(__name__)
config_name = config_name or os.environ.get("FLASK_CONFIG", "default")
app.config.from_object(config[config_name])
validate_config(app)
return app
def validate_config(app):
if not app.config.get("SQLALCHEMY_DATABASE_URI"):
raise RuntimeError("SQLALCHEMY_DATABASE_URI is required")
# .env.example
FLASK_CONFIG=development
SECRET_KEY=
DATABASE_URL=
Best Practices
✅ Do
- Separate config from code — anything that varies per deployment goes in the environment.
- Use config classes with inheritance so shared defaults live in one place.
- Read secrets from
os.environ; use.envonly for local development. - Commit a
.env.exampleso teammates know what to provide. - Validate at startup and fail fast on missing or dangerous values.
⚠️ Don't
- Don't commit secrets — no keys, passwords, or
.envfiles in Git, ever. - Don't ship a default
SECRET_KEYto production. - Don't run with
DEBUG=Truein production — it exposes an interactive shell to attackers. - Don't scatter
os.environreads across the codebase; centralize them in config.
Summary & Quiz
🎉 Key Takeaways
- Configuration is everything that isn't code; separate it from code and store per-deployment values in the environment.
- Config classes with inheritance keep shared defaults in one place and per-environment overrides minimal.
- Load individual secrets from
os.environ; usepython-dotenvand.envfor local dev only. - Instance folders +
.gitignorekeep secrets out of version control; secret managers handle production. - Pydantic Settings add type safety, and startup validation makes misconfigurations fail fast.
🎯 Quick Quiz
Question 1: According to the Twelve-Factor App methodology, where should values that vary between deployments live?
Question 2: What is the main benefit of loading config via python-dotenv and a .env file during development?
Question 3: Why validate configuration inside create_app() at startup?
📚 Further Reading
🚀 What's Next?
With factories, blueprints, and configuration under your belt, you're ready to build real APIs. Next: Flask-RESTful Extension, which adds structured, class-based resources for clean REST endpoints.
🎉 Solid foundation!
Your app can now run anywhere, keep its secrets, and refuse to boot when something's wrong.