6.12 Application Configuration
Table of Contents
Why Application Configuration Matters
Every backend application has values that change between environments, such as:
- Database connection URLs
- API keys and secrets
- Debug flags
- Email server settings
- External service endpoints
These should not be hard-coded in your source code. Instead, you load them from configuration, so you can:
- Use different settings in development, testing, and production
- Rotate secrets without changing code
- Share the same codebase for many deployments
- Keep sensitive data out of version control
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.
| Type | Examples | Often stored in |
|---|---|---|
| Environment selection | ENV=dev, ENV=prod | Environment variables |
| Security / secrets | DB password, API keys, JWT secret | Environment variables or secret manager |
| Infrastructure | DB host, Redis host, ports | Environment variables, .env files, YAML/JSON |
| Feature flags | FEATURE_X_ENABLED=true | Environment variables or config files |
| App behavior | Pagination size, cache TTL, log level | Config files with defaults + overrides |
| Third‑party services | Payment API URL, email SMTP, OAuth settings | Config files + secrets as environment variables |
The general pattern that works well in most projects:
- Put safe defaults in code or versioned config files.
- Override with environment variables for each environment.
- Keep secrets only in environment variables or a secure secret store.
Configuration per Environment
Most web backends use at least three environments:
- Development: for local work on your machine.
- Testing / Staging: for automated tests and pre‑production checks.
- Production: for real users.
You usually want different configuration in each environment. For example:
| Setting | Development | Testing | Production |
|---|---|---|---|
| Database URL | Local Postgres | Separate test DB | Managed cloud Postgres |
| Debug mode | Enabled | Disabled | Disabled |
| Logging level | DEBUG | INFO | WARNING or ERROR |
| Allowed origins (CORS) | http://localhost:3000 | Test frontend URL | Real frontend domain |
| Email sending | Console / sandbox service | Sandbox service | Real email provider |
A simple pattern is to have a single environment variable that defines the environment:
# .env or shell config
APP_ENV=development # or: testing, productionThen inside your application:
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
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:
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=mypasswordRule: 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:
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:
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:
.env
.env.*This keeps your local secrets and configuration out of version control.
You can create multiple .env files if needed:
.env.development.env.testing.env.production(often only on the server, not in your repo)
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:
- Reads environment variables and files
- Validates values
- Provides a single object other code can import
A simple version:
# 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:
# app/main.py
from .config import settings
print(settings.database_url)
print(settings.debug)Advantages:
- All configuration in one place
- Easy to see what is required
- Easy to validate and add defaults
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:
- Port to listen on
- Hostname or bind address
- Number of worker processes
- Whether to use HTTPS at this layer or at a reverse proxy
Example: running a FastAPI app with Uvicorn using environment variables:
# .env
HOST=0.0.0.0
PORT=8000
UVICORN_RELOAD=true# 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:
# 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.pyDatabase Configuration
Backends nearly always need database configuration. Common settings:
- URL or separate parts: host, port, username, password, database name
- Connection pool size
- SSL requirements
A typical pattern is a single database URL:
DATABASE_URL=postgresql+psycopg2://user:pass@db:5432/mydbThen in your app:
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:
# .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_dbLogging and Debug Configuration
Configuration should control how much information your backend logs and whether it runs in debug mode.
Common flags:
DEBUG=trueorfalseLOG_LEVEL=DEBUG|INFO|WARNING|ERROR|CRITICAL
Example:
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:
# .env.development
DEBUG=true
LOG_LEVEL=DEBUG
# .env.production
DEBUG=false
LOG_LEVEL=INFOFeature 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:
# .env
FEATURE_NEW_CHECKOUT=true
DEFAULT_PAGE_SIZE=20
MAX_PAGE_SIZE=100import 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:
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:
- Payment providers
- Email services
- Object storage (for file uploads)
- SMS gateways
Every such integration needs configuration:
- Base URL
- API key or client ID / secret
- Timeouts
- Whether to use sandbox or live mode
Example for an email provider:
EMAIL_PROVIDER_URL=https://api.mailprovider.com/v1
EMAIL_API_KEY=dev-api-key
EMAIL_FROM=no-reply@yourapp.dev
EMAIL_TIMEOUT_SECONDS=5import 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:
# 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-keyThe code does not change, only configuration:
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:
- Database passwords
- JWT signing keys
- API keys and tokens
- OAuth client 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:
- Use environment variables for secrets, not config files in Git.
- In development,
.envfiles are acceptable if they are ignored by Git. - Avoid printing configuration objects directly, because they may include secrets.
- In error messages, do not include secret values.
Example of a safe configuration representation:
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:
- Validate configuration when the application starts
- Fail fast with clear error messages
For example:
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 excIf 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:
- Hard coded defaults in code
- Defaults from configuration files (like
config.yaml) - Environment variables
- 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:
app_env: development
debug: true
log_level: DEBUG
port: 8000
database_url: postgresql://dev_user:dev_pass@localhost:5432/dev_db
config.production.yaml:
app_env: production
debug: false
log_level: INFO
port: 8000Python code:
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:
- Defaults in code
- Overrides from environment variables or
.envfiles
Example: Putting It All Together
Here is a minimal but realistic configuration setup for a small FastAPI backend.
.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:
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:
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:
- Treat configuration as external to the code
- Use environment variables and
.envfiles to manage settings - Create a centralized settings module to read, validate, and expose configuration
- Configure important areas such as server ports, databases, logging, feature flags, and external integrations
- Handle secrets safely and validate configuration early
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
KAHIBARO