Skip to main content

⚙️ 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.config object
  • Organize config classes with inheritance for dev, test, and production
  • Load configuration from objects, environment variables, and .env files
  • 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.

flowchart LR ENV[Environment Variables] --> CFG[Config Layer] DOT[".env file (dev)"] --> CFG CLS[Config Classes] --> CFG CFG --> APP["create_app()"] APP --> DEV[Development] APP --> TEST[Testing] APP --> PROD[Production]

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:

KeyPurposeDefault
SECRET_KEYSigns session cookies and CSRF tokens — must be secret and randomNone
DEBUGEnables the interactive debugger and reloader (never on in prod)False
TESTINGPropagates exceptions and tweaks behavior for testsFalse
SERVER_NAMEHost/port for subdomains and out-of-context URL buildingNone
SESSION_COOKIE_SECUREOnly send the session cookie over HTTPSFalse

⚠️ 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.

MethodSourceTypical use
from_object("config.Prod")A Python class/modulePer-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 varIndividual 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 int 587 automatically.
  • Required fields: a missing SQLALCHEMY_DATABASE_URI fails 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:

  1. Write config.py with Config, DevelopmentConfig, TestingConfig, ProductionConfig, and a config dict.
  2. Have SECRET_KEY and SQLALCHEMY_DATABASE_URI read from os.environ with dev-friendly defaults (production URI: no default).
  3. Create a .env (git-ignored) and a committed .env.example.
  4. In create_app(), pick the config by FLASK_CONFIG, load it, and call a validate_config() that raises if the database URI is missing.
  5. Confirm create_app("testing") yields sqlite:///: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 .env only for local development.
  • Commit a .env.example so 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 .env files in Git, ever.
  • Don't ship a default SECRET_KEY to production.
  • Don't run with DEBUG=True in production — it exposes an interactive shell to attackers.
  • Don't scatter os.environ reads 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; use python-dotenv and .env for local dev only.
  • Instance folders + .gitignore keep 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.