KAHIBARO
Discord Login Register

6.12 Application Configuration

Why Application Configuration Matters

Every backend application has values that change between environments, such as:

These should not be hard-coded in your source code. Instead, you load them from configuration, so you can:

Rule: Never hard‑code secrets, passwords, API keys, or production URLs in your source code. Always load them from configuration.

In this chapter we focus on how to structure and load configuration in a typical web backend, with many concrete examples. We assume you already know what environment variables are from earlier chapters, so we do not reintroduce them from scratch.

Types of Configuration

You can group configuration into several categories. This helps you decide where each piece should live.

TypeExamplesOften stored in
Environment selectionENV=dev, ENV=prodEnvironment variables
Security / secretsDB password, API keys, JWT secretEnvironment variables or secret manager
InfrastructureDB host, Redis host, portsEnvironment variables, .env files, YAML/JSON
Feature flagsFEATURE_X_ENABLED=trueEnvironment variables or config files
App behaviorPagination size, cache TTL, log levelConfig files with defaults + overrides
Third‑party servicesPayment API URL, email SMTP, OAuth settingsConfig files + secrets as environment variables

The general pattern that works well in most projects:

  1. Put safe defaults in code or versioned config files.
  2. Override with environment variables for each environment.
  3. Keep secrets only in environment variables or a secure secret store.

Configuration per Environment

Most web backends use at least three environments:

You usually want different configuration in each environment. For example:

SettingDevelopmentTestingProduction
Database URLLocal PostgresSeparate test DBManaged cloud Postgres
Debug modeEnabledDisabledDisabled
Logging levelDEBUGINFOWARNING or ERROR
Allowed origins (CORS)http://localhost:3000Test frontend URLReal frontend domain
Email sendingConsole / sandbox serviceSandbox serviceReal email provider

A simple pattern is to have a single environment variable that defines the environment:

bash
# .env or shell config
APP_ENV=development  # or: testing, production

Then inside your application:

python
import os
APP_ENV = os.getenv("APP_ENV", "development")
if APP_ENV == "development":
    DEBUG = True
    LOG_LEVEL = "DEBUG"
elif APP_ENV == "testing":
    DEBUG = False
    LOG_LEVEL = "INFO"
elif APP_ENV == "production":
    DEBUG = False
    LOG_LEVEL = "WARNING"
else:
    raise ValueError(f"Unknown APP_ENV: {APP_ENV}")

This lets you switch behavior just by changing APP_ENV.

Using Environment Variables

Environment variables are the most common way to configure backend applications.

Reading environment variables in Python

python
import os
# Required variable, raise error if missing
DATABASE_URL = os.environ["DATABASE_URL"]
# Optional with default
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# Boolean flag from string
def str_to_bool(value: str, default: bool = False) -> bool:
    if value is None:
        return default
    return value.lower() in ("1", "true", "yes", "y", "on")
DEBUG = str_to_bool(os.getenv("DEBUG"), default=True)

Examples of environment variables for a web backend:

bash
APP_ENV=production
PORT=8000
DATABASE_URL=postgresql+psycopg2://user:pass@db:5432/mydb
REDIS_URL=redis://redis:6379/0
JWT_SECRET=super-secret-key
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=587
SMTP_USER=myuser
SMTP_PASSWORD=mypassword

Rule: Your application should start and configure itself only from configuration and environment variables, never from values hard‑coded for a specific machine.

Using .env Files

Typing many environment variables by hand is annoying. In development you usually have a .env file in your project root, not committed to Git, that stores your local variables.

Example .env file:

env
APP_ENV=development
PORT=8000
DATABASE_URL=postgresql://dev_user:dev_pass@localhost:5432/dev_db
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=dev-secret-key
EMAIL_FROM=no-reply@example.local

Then use a library such as python-dotenv or FastAPI's BaseSettings (from Pydantic) to load it.

Example with python-dotenv:

python
from dotenv import load_dotenv
import os
load_dotenv()  # loads variables from .env
PORT = int(os.getenv("PORT", "8000"))
DATABASE_URL = os.environ["DATABASE_URL"]

Typical .gitignore entry:

gitignore
.env
.env.*

This keeps your local secrets and configuration out of version control.

You can create multiple .env files if needed:

Then choose which one to load based on APP_ENV.

Centralized Settings Module

As your application grows, configuration spread across many files becomes messy. A good practice is to have a single settings module that:

A simple version:

python
# app/config.py
import os
from dataclasses import dataclass
def str_to_bool(value: str, default: bool = False) -> bool:
    if value is None:
        return default
    return value.lower() in ("1", "true", "yes", "y", "on")
@dataclass
class Settings:
    app_env: str
    debug: bool
    port: int
    database_url: str
    redis_url: str
    jwt_secret: str
def get_settings() -> Settings:
    app_env = os.getenv("APP_ENV", "development")
    debug = str_to_bool(os.getenv("DEBUG"), app_env == "development")
    port = int(os.getenv("PORT", "8000"))
    database_url = os.environ.get("DATABASE_URL")
    if not database_url:
        raise RuntimeError("DATABASE_URL is required")
    redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
    jwt_secret = os.environ.get("JWT_SECRET")
    if not jwt_secret:
        raise RuntimeError("JWT_SECRET is required")
    return Settings(
        app_env=app_env,
        debug=debug,
        port=port,
        database_url=database_url,
        redis_url=redis_url,
        jwt_secret=jwt_secret,
    )
settings = get_settings()

Then elsewhere:

python
# app/main.py
from .config import settings
print(settings.database_url)
print(settings.debug)

Advantages:

Later, with FastAPI, you will often use Pydantic's BaseSettings for this, but the idea is the same.

Configuration for Web Servers

A web backend usually has at least these configuration items:

Example: running a FastAPI app with Uvicorn using environment variables:

bash
# .env
HOST=0.0.0.0
PORT=8000
UVICORN_RELOAD=true
python
# run.py
import os
import uvicorn
HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "8000"))
RELOAD = os.getenv("UVICORN_RELOAD", "false").lower() == "true"
if __name__ == "__main__":
    uvicorn.run(
        "app.main:app",
        host=HOST,
        port=PORT,
        reload=RELOAD,
    )

Then:

bash
# development
UVICORN_RELOAD=true python run.py
# production (usually started by a process manager)
UVICORN_RELOAD=false HOST=0.0.0.0 PORT=80 python run.py

Database Configuration

Backends nearly always need database configuration. Common settings:

A typical pattern is a single database URL:

bash
DATABASE_URL=postgresql+psycopg2://user:pass@db:5432/mydb

Then in your app:

python
import os
from sqlalchemy import create_engine
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
    pool_size=int(os.getenv("DB_POOL_SIZE", "5")),
    max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "10")),
)

You can use different URLs per environment:

bash
# .env.development
DATABASE_URL=postgresql://dev_user:dev_pass@localhost:5432/dev_db
# .env.testing
DATABASE_URL=postgresql://test_user:test_pass@localhost:5432/test_db
# on production server (not in Git)
DATABASE_URL=postgresql://prod_user:prod_pass@prod-db:5432/prod_db

Logging and Debug Configuration

Configuration should control how much information your backend logs and whether it runs in debug mode.

Common flags:

Example:

python
import logging
import os
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if DEBUG else "INFO")
logging.basicConfig(
    level=LOG_LEVEL,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
logger.debug("Debug mode is %s", "on" if DEBUG else "off")

Then you control it per environment:

bash
# .env.development
DEBUG=true
LOG_LEVEL=DEBUG
# .env.production
DEBUG=false
LOG_LEVEL=INFO

Feature Flags and Behavior Settings

Sometimes you want to turn features on or off without redeploying the application. You can use feature flags from configuration.

Example:

bash
# .env
FEATURE_NEW_CHECKOUT=true
DEFAULT_PAGE_SIZE=20
MAX_PAGE_SIZE=100
python
import os
FEATURE_NEW_CHECKOUT = os.getenv("FEATURE_NEW_CHECKOUT", "false").lower() == "true"
DEFAULT_PAGE_SIZE = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
MAX_PAGE_SIZE = int(os.getenv("MAX_PAGE_SIZE", "100"))
def get_page_size(requested: int | None) -> int:
    if requested is None:
        return DEFAULT_PAGE_SIZE
    return max(1, min(requested, MAX_PAGE_SIZE))

In an endpoint you might use:

python
if FEATURE_NEW_CHECKOUT:
    return do_new_checkout()
return do_old_checkout()

Later, you can switch FEATURE_NEW_CHECKOUT to false in configuration if there is a problem, without changing code.

Configuration for External Services

Most real backends integrate with some external services, such as:

Every such integration needs configuration:

Example for an email provider:

bash
EMAIL_PROVIDER_URL=https://api.mailprovider.com/v1
EMAIL_API_KEY=dev-api-key
EMAIL_FROM=no-reply@yourapp.dev
EMAIL_TIMEOUT_SECONDS=5
python
import os
EMAIL_PROVIDER_URL = os.getenv("EMAIL_PROVIDER_URL")
EMAIL_API_KEY = os.environ["EMAIL_API_KEY"]
EMAIL_FROM = os.getenv("EMAIL_FROM", "no-reply@example.com")
EMAIL_TIMEOUT_SECONDS = float(os.getenv("EMAIL_TIMEOUT_SECONDS", "5.0"))

For a payment provider sandbox vs production:

bash
# development
PAYMENT_API_URL=https://sandbox-api.payments.com
PAYMENT_API_KEY=sandbox-key
# production
PAYMENT_API_URL=https://api.payments.com
PAYMENT_API_KEY=prod-secret-key

The code does not change, only configuration:

python
PAYMENT_API_URL = os.environ["PAYMENT_API_URL"]
PAYMENT_API_KEY = os.environ["PAYMENT_API_KEY"]

Secure Handling of Secrets

Certain configuration values are secrets:

They must be handled with extra care.

Rule: Secrets must never be committed to Git, printed in logs, or returned in API responses.

Practical rules:

  1. Use environment variables for secrets, not config files in Git.
  2. In development, .env files are acceptable if they are ignored by Git.
  3. Avoid printing configuration objects directly, because they may include secrets.
  4. In error messages, do not include secret values.

Example of a safe configuration representation:

python
from dataclasses import dataclass, asdict
@dataclass
class Settings:
    database_url: str
    jwt_secret: str
    debug: bool
    def safe_dict(self) -> dict:
        data = asdict(self)
        if "jwt_secret" in data:
            data["jwt_secret"] = "***hidden***"
        if "database_url" in data:
            data["database_url"] = "***hidden***"
        return data
settings = Settings(
    database_url="postgresql://user:pass@localhost/db",
    jwt_secret="super-secret",
    debug=True,
)
print(settings.safe_dict())
# {'database_url': '***hidden***', 'jwt_secret': '***hidden***', 'debug': True}

Validation and Failing Fast

Configuration errors are a common cause of failures, so it is important to:

For example:

python
import os
def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value
DATABASE_URL = require_env("DATABASE_URL")
JWT_SECRET = require_env("JWT_SECRET")
PORT_STR = os.getenv("PORT", "8000")
try:
    PORT = int(PORT_STR)
except ValueError as exc:
    raise RuntimeError(f"Invalid PORT value: {PORT_STR}") from exc

If a variable is missing or invalid, the application exits immediately instead of failing later at runtime with a more confusing error.

Configuration Hierarchy and Overrides

A common pattern is multiple layers of configuration, where each layer overrides the previous one:

  1. Hard coded defaults in code
  2. Defaults from configuration files (like config.yaml)
  3. Environment variables
  4. Command line flags

You might not need all these layers, but it is useful to understand the idea.

An example with YAML and environment variables:

config.default.yaml:

yaml
app_env: development
debug: true
log_level: DEBUG
port: 8000
database_url: postgresql://dev_user:dev_pass@localhost:5432/dev_db

config.production.yaml:

yaml
app_env: production
debug: false
log_level: INFO
port: 8000

Python code:

python
import os
import yaml
def load_yaml_config(filename: str) -> dict:
    with open(filename, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)
env = os.getenv("APP_ENV", "development")
config = load_yaml_config("config.default.yaml")
if env == "production":
    prod_config = load_yaml_config("config.production.yaml")
    config.update(prod_config)
# Environment variables override file config
config["port"] = int(os.getenv("PORT", config.get("port", 8000)))
config["database_url"] = os.getenv("DATABASE_URL", config["database_url"])

You can then access config["port"] anywhere.

For many small to medium projects you can keep it simpler and skip YAML, using only:

Example: Putting It All Together

Here is a minimal but realistic configuration setup for a small FastAPI backend.

.env:

env
APP_ENV=development
DEBUG=true
PORT=8000
DATABASE_URL=postgresql://dev_user:dev_pass@localhost:5432/dev_db
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=dev-secret
ACCESS_TOKEN_EXPIRE_MINUTES=30
LOG_LEVEL=DEBUG
EMAIL_FROM=no-reply@example.local
EMAIL_PROVIDER_URL=https://api.mailprovider.test
EMAIL_API_KEY=dev-mail-key
FEATURE_NEW_CHECKOUT=false

app/config.py:

python
from dataclasses import dataclass
import os
from dotenv import load_dotenv
load_dotenv()  # load from .env
def str_to_bool(value: str | None, default: bool = False) -> bool:
    if value is None:
        return default
    return value.lower() in ("1", "true", "yes", "y", "on")
def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value
@dataclass
class Settings:
    app_env: str
    debug: bool
    port: int
    database_url: str
    redis_url: str
    jwt_secret: str
    access_token_expire_minutes: int
    log_level: str
    email_from: str
    email_provider_url: str
    email_api_key: str
    feature_new_checkout: bool
def get_settings() -> Settings:
    app_env = os.getenv("APP_ENV", "development")
    debug = str_to_bool(os.getenv("DEBUG"), app_env == "development")
    port = int(os.getenv("PORT", "8000"))
    database_url = require_env("DATABASE_URL")
    redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
    jwt_secret = require_env("JWT_SECRET")
    access_token_expire_minutes = int(
        os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")
    )
    log_level = os.getenv("LOG_LEVEL", "DEBUG" if debug else "INFO")
    email_from = os.getenv("EMAIL_FROM", "no-reply@example.com")
    email_provider_url = require_env("EMAIL_PROVIDER_URL")
    email_api_key = require_env("EMAIL_API_KEY")
    feature_new_checkout = str_to_bool(
        os.getenv("FEATURE_NEW_CHECKOUT"),
        default=False,
    )
    return Settings(
        app_env=app_env,
        debug=debug,
        port=port,
        database_url=database_url,
        redis_url=redis_url,
        jwt_secret=jwt_secret,
        access_token_expire_minutes=access_token_expire_minutes,
        log_level=log_level,
        email_from=email_from,
        email_provider_url=email_provider_url,
        email_api_key=email_api_key,
        feature_new_checkout=feature_new_checkout,
    )
settings = get_settings()

In app/main.py:

python
from fastapi import FastAPI
from .config import settings
app = FastAPI(debug=settings.debug)
@app.get("/health")
def health():
    return {
        "status": "ok",
        "env": settings.app_env,
        "debug": settings.debug,
    }
@app.get("/checkout")
def checkout():
    if settings.feature_new_checkout:
        return {"message": "Using new checkout flow"}
    return {"message": "Using old checkout flow"}

Then you switch behavior just by changing environment variables or .env contents, without touching your application code.

Summary

In this chapter you learned how to:

This configuration foundation prepares you to structure and run your web backends consistently across different environments, which becomes even more important when you start building larger applications and deploying them to production.

Views: 7

Comments

Please login to add a comment.

Don't have an account? Register now!