Lesson 2 - Configuration and Secrets

Welcome to Configuration and Secrets

Your Task Manager has values that change depending on where it runs: the database URL, how long an access token stays valid, and the secret key used to sign those tokens. So far those values have lived right in your code as literals. That’s fine while you’re learning, but it’s both inflexible and unsafe — you’d have to edit and re-deploy the code just to point at a different database, and worst of all, a secret key sitting in a source file is one git push away from being public forever.

This lesson fixes that with pydantic-settings: you describe your configuration as a typed class, and it loads the values from the environment for you — with the same conversion and validation you already trust from Pydantic. Your code stays the same everywhere; only the environment changes.

By the end of this lesson, you will be able to:

  • Explain why hardcoded configuration and secrets are a problem
  • Define typed settings with pydantic-settings and sensible defaults
  • Override settings from environment variables, namespaced with a prefix
  • Load settings from a .env file and keep secrets out of your codebase

You already know Pydantic models; settings are the same idea pointed at your environment. Let’s begin.


The Problem With Hardcoded Config

Look at how configuration tends to start out — values written directly into the code:

SECRET_KEY = "CHANGE-ME-IN-PRODUCTION-USE-A-LONG-RANDOM-SECRET"
ACCESS_TOKEN_MINUTES = 30
DATABASE_URL = "sqlite:///app.db"

This works, but it has two serious flaws:

  • It’s inflexible. Your laptop, a teammate’s machine, a staging server, and production all need different values — a different database, maybe a longer token lifetime. With the values baked into the code, the only way to change one is to edit the source and re-deploy. The same code can’t run in two places with two configurations.
  • It’s unsafe. That SECRET_KEY is a real secret: anyone who has it can forge valid login tokens. Putting it in a source file means it lands in version control, and once a secret is committed it’s effectively leaked — rotating it is the only safe fix. Secrets must never live in code.

The fix is to keep these values out of the code and read them from the environment at startup. Code that contains no secrets and no machine-specific paths can be shared, committed, and deployed anywhere unchanged.


Typed Settings With pydantic-settings

pydantic-settings lets you declare your configuration as a class, exactly like a Pydantic model — typed fields with defaults. The difference is that it automatically pulls values from the environment. Install it first:

pip install pydantic-settings

Now define a Settings class. Put this in a config.py for your Task Manager:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    secret_key: str = "CHANGE-ME-IN-PRODUCTION-USE-A-LONG-RANDOM-SECRET"
    access_token_minutes: int = 30
    database_url: str = "sqlite:///app.db"

Each field has a type and a default. When you construct Settings(), pydantic-settings looks for a matching value in the environment for each field; if it doesn’t find one, it falls back to the default. With nothing set in the environment, you get exactly the defaults:

settings = Settings()
print("secret_key:", settings.secret_key[:15] + "...")
print("access_token_minutes:", settings.access_token_minutes)
print("database_url:", settings.database_url)
print("type:", type(settings.access_token_minutes).__name__)
secret_key: CHANGE-ME-IN-PR...
access_token_minutes: 30
database_url: sqlite:///app.db
type: int

(We print only the first 15 characters of the secret — you never print a real one in full.) Notice access_token_minutes is an int, not a string. That’s the Pydantic part: fields are typed, converted, and validated. Throughout your app you now from config import settings and read settings.secret_key instead of a hardcoded literal — one object holds all your configuration.

The model_config = SettingsConfigDict(...) line is how you configure the settings class itself; the env_prefix it sets is what we’ll look at next.


Environment Variables Override the Defaults

The whole point is that the environment wins. When an environment variable matches a field, its value overrides the default — and because the field is typed, the string from the environment is converted for you.

The env_prefix="APP_" from the previous section namespaces your variables. Without a prefix, a field named database_url would read a generic DATABASE_URL env var — which might collide with something else on the machine. With the prefix, it reads APP_DATABASE_URL instead, so all of your app’s settings share a clear, unique namespace.

Here’s the override in action. We set APP_ACCESS_TOKEN_MINUTES in the environment before constructing Settings():

import os
from config import Settings

# Before: nothing set, default is used
print("default :", Settings().access_token_minutes)

# Set the environment variable, then construct again
os.environ["APP_ACCESS_TOKEN_MINUTES"] = "60"
print("override:", Settings().access_token_minutes)
print("type    :", type(Settings().access_token_minutes).__name__)
default : 30
override: 60
type    : int

The value jumped from 30 to 60 purely because the environment changed — no code edit. And note the env var was the string "60", but the field came out as the integer 60, because the field is declared access_token_minutes: int. (In real use you set the variable in your shell or your deployment platform, e.g. export APP_ACCESS_TOKEN_MINUTES=60; we set it via os.environ here only to demonstrate it in one script.)

That conversion is also validation. If the environment gave a value the type can’t accept, you get a clear ValidationError at startup — the same protection Pydantic gives every model — rather than a confusing crash later. Setting APP_ACCESS_TOKEN_MINUTES="not-a-number" raises:

ValidationError
1 validation error for Settings

Failing loudly at startup is exactly what you want: a misconfigured app refuses to run instead of misbehaving in production.


.env Files and Keeping Secrets Out of Code

Exporting a dozen variables by hand on every machine gets tedious. For local development, pydantic-settings can read them from a .env file — a simple KEY=value text file in your project. Point your settings at it with env_file:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
    secret_key: str = "CHANGE-ME-IN-PRODUCTION-USE-A-LONG-RANDOM-SECRET"
    access_token_minutes: int = 30
    database_url: str = "sqlite:///app.db"

Create a .env next to it (the prefix applies to the file too):

APP_ACCESS_TOKEN_MINUTES=120
APP_DATABASE_URL=sqlite:///tasks.db

Now Settings() reads those values automatically:

settings = Settings()
print("access_token_minutes:", settings.access_token_minutes)
print("database_url:", settings.database_url)
print("secret_key:", settings.secret_key[:15] + "...")
access_token_minutes: 120
database_url: sqlite:///tasks.db
secret_key: CHANGE-ME-IN-PR...

access_token_minutes and database_url came from the file; secret_key wasn’t in the file, so it kept its default. The .env file is purely a convenience for loading the environment — the result is identical to exporting the variables yourself.

Here’s the rule that makes this safe: the .env file must never be committed. Add it to your .gitignore:

# .gitignore
.env

A .env is for local values, including any local secrets, and it stays on your machine only. In production, you don’t ship a .env at all — your real secrets come from the environment directly, injected by your hosting platform or a dedicated secrets manager (the kind of vault your deployment service provides). The principle is the same everywhere: secrets live in the environment, never in the code and never in version control.

Never commit .env or secrets

Add .env to .gitignore before you create it — once a secret is committed, it’s compromised even if you delete it later, because it lives in your git history. Remember the precedence: an actual environment variable overrides the .env file, which overrides the field’s default. And because Settings is a Pydantic model, every value is type-checked and validated at startup, so a misconfigured app fails immediately instead of silently.


Practice Exercises

Exercise 1: Why not hardcode the secret key?

Your teammate suggests just leaving SECRET_KEY = "..." in config.py “for now.” Give two distinct reasons that’s a bad idea.

Hint

First, it’s a leaked secret: committing it to git puts it in your history permanently, and anyone with the key can forge valid tokens — the only fix is to rotate the key. Second, it’s inflexible: every environment (laptop, staging, production) should use a different key, which is impossible if it’s baked into the shared code. Read it from the environment instead.

Exercise 2: Predict the override

access_token_minutes defaults to 30. Your .env file contains APP_ACCESS_TOKEN_MINUTES=120, and your shell also has export APP_ACCESS_TOKEN_MINUTES=45. What value does Settings().access_token_minutes return, and as what type?

Hint

It returns 45, as an int. An actual environment variable takes precedence over the .env file, which takes precedence over the default — so the shell’s 45 wins. And because the field is declared access_token_minutes: int, the string "45" is converted to the integer 45.

Exercise 3: The prefix matters

You set the environment variable DATABASE_URL (no prefix) but settings.database_url still shows the default. Why, and what should you set instead?

Hint

Your class uses env_prefix="APP_", so it only reads variables that start with APP_. A bare DATABASE_URL doesn’t match and is ignored. Set APP_DATABASE_URL instead — the prefix namespaces all of your app’s settings so they don’t collide with other variables on the machine.


Summary

Hardcoded configuration is inflexible (the same code can’t run with different values in different places) and unsafe (secrets in code end up committed and leaked). pydantic-settings solves both: you define a BaseSettings subclass with typed fields and defaults, and it loads values from the environment automatically — with Pydantic’s type conversion and validation. An env_prefix (like APP_) namespaces your variables; an .env file (referenced via SettingsConfigDict(env_file=".env")) makes local development convenient. Environment variables override the .env file, which overrides defaults. Crucially, the .env belongs in .gitignore, and in production secrets come from the environment or a secrets manager — never from code.

Key Concepts

  • pydantic-settings — define configuration as a typed BaseSettings class.
  • BaseSettings — like a Pydantic model, but it reads fields from the environment.
  • env_prefix — namespaces your variables (APP_DATABASE_URL, not DATABASE_URL).
  • env_file — a local .env of KEY=value lines; never committed.
  • Precedence — environment variable > .env file > field default.
  • Secrets — live in the environment or a secrets manager, never in code or git.

Why This Matters

Configuration management is what lets one codebase run safely across your laptop, staging, and production without edits — and it’s the line between an API that’s deployable and one that leaks its keys the moment it’s pushed. Reading settings from the environment with pydantic-settings is the standard, professional pattern, and you’ll rely on it when you deploy the Task Manager in the capstone: your secret key and database URL will come from the environment, while your code stays clean and shareable.


Next Steps

Continue to Lesson 3 - Docs, Metadata, and Deployment

Polish your API's auto-generated docs with metadata, then take your Task Manager from your laptop to a live deployment.

Back to Module Overview

Return to the Testing, Settings, Deployment, and Capstone module overview


Continue Building Your Skills

Your configuration now lives where it belongs — in the environment, typed and validated, with secrets out of your codebase entirely. That’s the last piece of making the Task Manager deployable. Next you’ll polish the API’s auto-generated documentation with helpful metadata and then take it live, putting everything from this course together into a real, running, configured, and tested deployment.