Chapter 17: Environment Variables , .env Setup + python-dotenv

Let’s Start With a Real Problem

Go back through this series and count how many times you’ve seen this comment:

# NOTE: Hardcoded placeholder secret for learning purposes only.
# Chapter 17 covers moving secrets like this into a proper .env file.

Since Chapter 10, every database_url, and since Chapter 14, every jwt_secret_key, has been sitting in plain sight, directly inside config.py — a file that would get committed straight to GitHub the moment you ran git push. If that repository were ever public, or even just shared with the wrong person, your database credentials and the secret key protecting every user’s login session would be exposed instantly. This chapter fixes that, permanently, for every project going forward.


What You’ll Learn

  • Why secrets should never live directly in your source code
  • How to use python-dotenv to load configuration from a .env file
  • How FastAPI’s recommended approach — pydantic-settings with env_file — builds on top of that same idea, with validation included
  • How to structure .env, .env.example, and .gitignore the way real teams do
  • Theory + hands-on assignment questions to lock in what you’ve learned

Theory: Why Environment Variables?

An environment variable is a value set outside your code, in the environment your program runs in — your terminal session, your hosting platform’s dashboard, a Docker container’s configuration, and so on. Your Python code can read these values at runtime, without that value ever being written down inside a .py file.

A .env file is simply a convenient way to define a bunch of these variables locally, in one place, formatted like this:

KEY=value
ANOTHER_KEY=another_value

The critical habit that makes this actually work as a security practice: .env is never committed to version control. Instead, teams commit a .env.example file — showing which variables are needed, with placeholder or dummy values — so anyone cloning the project knows exactly what to configure, without ever seeing real secrets.

FileCommitted to Git?Contains
.envNo — listed in .gitignoreReal secrets and local configuration
.env.exampleYesVariable names only, with placeholder values

Program 1: Reading Configuration with python-dotenv

Program Objective

Build a small, standalone FastAPI project that reads simple configuration values from a .env file using python-dotenv directly — understanding the fundamental mechanism before we let pydantic-settings handle it more elegantly in Program 2.

Step-by-Step Implementation

Step 1: Create the project

uv init dotenv-basics-demo
cd dotenv-basics-demo
uv add fastapi --extra standard
uv add python-dotenv

Step 2: Create the project structure

mkdir -p app
touch app/__init__.py
dotenv-basics-demo/
├── app/
│   ├── __init__.py
│   └── main.py
├── .env
├── .env.example
├── .gitignore
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

.env

APP_NAME=Dotenv Basics Demo
DEBUG=True
MAX_UPLOAD_SIZE_MB=10

.env.example

APP_NAME=Change Me
DEBUG=False
MAX_UPLOAD_SIZE_MB=5

.gitignore

.env
__pycache__/
*.pyc
.venv/

app/main.py

import os

from dotenv import load_dotenv
from fastapi import FastAPI

# Reads the .env file and loads its key-value pairs into the process environment.
load_dotenv()

app_name = os.getenv("APP_NAME", "Default App Name")
debug_mode = os.getenv("DEBUG", "False").lower() == "true"
max_upload_size_mb = int(os.getenv("MAX_UPLOAD_SIZE_MB", "5"))

app = FastAPI(title=app_name)


@app.get("/", tags=["Root"])
def read_root():
    return {
        "app_name": app_name,
        "debug_mode": debug_mode,
        "max_upload_size_mb": max_upload_size_mb,
    }

Detailed Code Explanation

  • .env holds the real, local values for this machine — this is the file that’s actually used when the app runs.
  • .env.example is a template with the same variable names, but harmless placeholder values — this is what gets committed to Git, so a new developer joining the project immediately knows what configuration is expected, without ever seeing real secrets.
  • .gitignore explicitly excludes .env from version control — this is the actual mechanism that prevents secrets from ever reaching GitHub in the first place. Without this line, .env would be committed like any other file.
  • load_dotenv() reads the .env file in your project’s root and copies its key-value pairs into os.environ for the current process — after this line runs, os.getenv(...) can see them as if they were set directly in your terminal.
  • os.getenv("APP_NAME", "Default App Name") — the second argument is a fallback used only if the variable isn’t set at all, which is a helpful safety net during development.
  • Notice everything from .env arrives as a plain string — that’s why debug_mode needs .lower() == "true" to become a real boolean, and max_upload_size_mb needs int(...) to become a real number. This manual type conversion is exactly the tedious, error-prone part that Program 2’s approach eliminates.

Execution & Testing

uv run fastapi dev app/main.py
  1. Open http://127.0.0.1:8000/ and confirm the response reflects the values from your .env file, not the defaults hardcoded in main.py.
  2. Change DEBUG=True to DEBUG=False in .env, restart the server, and confirm the response updates accordingly — proving the value genuinely comes from the file, not from Python code.
  3. Temporarily rename .env to .env.bak and restart the server — confirm the app falls back to the hardcoded defaults ("Default App Name", etc.) without crashing, then rename it back.

Program 2: The FastAPI-Recommended Approach — pydantic-settings + .env

Program Objective

Rebuild the full authentication project from Chapter 16 so that every sensitive or environment-specific value — database URL, JWT secret key, token expiry — is loaded from a .env file through pydantic-settings, with automatic type validation and zero manual os.getenv calls anywhere in the codebase.

Step-by-Step Implementation

Step 1: Create the project

uv init secure-config-demo
cd secure-config-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"

💡 pydantic-settings already depends on python-dotenv internally — you don’t need to add it separately or call load_dotenv() yourself. It reads your .env file automatically once you tell it where to look.

Step 2: Create the project structure

mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
secure-config-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── security.py
│   │   └── dependencies.py
│   ├── db/
│   │   ├── __init__.py
│   │   └── database.py
│   ├── models/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── token.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       ├── users.py
│       └── auth.py
├── .env
├── .env.example
├── .gitignore
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

.env

APP_NAME=Secure Config Demo
APP_VERSION=1.0.0
DATABASE_URL=sqlite:///./app.db
JWT_SECRET_KEY=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a1
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30

💡 The JWT_SECRET_KEY above is just a random example. Generate your own with openssl rand -hex 32 in your terminal — never reuse a secret key you’ve seen in a tutorial or example.

.env.example

APP_NAME=Secure Config Demo
APP_VERSION=1.0.0
DATABASE_URL=sqlite:///./app.db
JWT_SECRET_KEY=replace-with-your-own-generated-secret
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30

.gitignore

.env
__pycache__/
*.pyc
.venv/
*.db

app/core/config.py

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str
    app_version: str
    database_url: str
    jwt_secret_key: str
    jwt_algorithm: str
    access_token_expire_minutes: int

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")


settings = Settings()

app/core/security.py

from datetime import datetime, timedelta, timezone

from jose import JWTError, jwt
from passlib.context import CryptContext

from app.core.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(plain_password: str) -> str:
    return pwd_context.hash(plain_password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)


def create_access_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
    to_encode = {"sub": subject, "exp": expire}
    return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_token(token: str) -> dict:
    try:
        return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
    except JWTError as exc:
        raise ValueError("Invalid or expired token.") from exc

app/core/dependencies.py

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session

from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")


def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db),
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = decode_token(token)
    except ValueError:
        raise credentials_exception

    username = payload.get("sub")
    if username is None:
        raise credentials_exception

    user = get_user_by_username(db, username)
    if user is None:
        raise credentials_exception

    return user

app/db/database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from app.core.config import settings

engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})

SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

app/models/user.py

from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from app.db.database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool

    model_config = ConfigDict(from_attributes=True)

app/schemas/token.py

from pydantic import BaseModel


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/services/user_service.py

from sqlalchemy.orm import Session

from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate


def create_user(db: Session, user_in: UserCreate) -> User:
    new_user = User(
        username=user_in.username,
        email=user_in.email,
        hashed_password=hash_password(user_in.password),
    )
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    return new_user


def get_user_by_username(db: Session, username: str) -> User | None:
    return db.query(User).filter(User.username == username).first()

app/routers/auth.py

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username

router = APIRouter(prefix="/auth", tags=["Auth"])


@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = get_user_by_username(db, form_data.username)

    if user is None or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password.",
        )

    access_token = create_access_token(subject=user.username)
    return TokenResponse(access_token=access_token)

app/routers/users.py

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.core.dependencies import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
    return create_user(db, user_in)


@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)):
    return current_user

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.db.database import Base, engine
from app.models import user  # noqa: F401
from app.routers import auth, users

Base.metadata.create_all(bind=engine)

app = FastAPI(title=settings.app_name, version=settings.app_version)

app.include_router(users.router)
app.include_router(auth.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": f"Welcome to {settings.app_name}"}

Detailed Code Explanation

  • The entire fix lives in config.py. Compare this to every previous chapter’s Settings class: those gave each field a hardcoded default value (database_url: str = "sqlite:///./app.db"). This version gives each field no default at all — just a type (str, int). That’s a deliberate, important change: it forces the app to genuinely load these values from somewhere real, rather than silently falling back to a value baked into your source code.
  • model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") is what tells pydantic-settings to automatically read your .env file — no load_dotenv() call needed anywhere, since pydantic-settings handles that internally.
  • Automatic type conversion, for free: notice access_token_expire_minutes: int — even though every value in a .env file is technically text, pydantic-settings automatically converts "30" into the real integer 30. This is the exact tedious manual conversion from Program 1 (int(os.getenv(...))) that this approach eliminates entirely.
  • Automatic validation, for free: if .env were missing JWT_SECRET_KEY entirely, or if ACCESS_TOKEN_EXPIRE_MINUTES contained something non-numeric like "thirty", your app would fail immediately on startup with a clear Pydantic validation error — not silently limp along with a broken or missing secret. Failing loudly and immediately is exactly the behavior you want for missing configuration, rather than discovering the problem later through a confusing runtime bug.
  • Every other file in this project — security.py, database.py, the routers — is completely unchanged from Chapter 16. They already referenced settings.database_url, settings.jwt_secret_key, and so on; they never cared where those values ultimately came from. This is the same architectural payoff you saw back in Chapter 10 when switching from SQLite to PostgreSQL — good separation of concerns means the fix stays contained to one file.

Execution & Testing

uv run fastapi dev app/main.py
  1. Confirm the app starts normally and POST /users/register → POST /auth/login → GET /users/me all work exactly as they did in Chapter 16.
  2. Prove the validation works: comment out the JWT_SECRET_KEY=... line in .env entirely, and try restarting the server. Confirm it fails immediately with a clear Pydantic error telling you exactly which field is missing — then uncomment the line and confirm it starts normally again.
  3. Prove type conversion works: temporarily set ACCESS_TOKEN_EXPIRE_MINUTES=not-a-number in .env and restart — confirm you get a clear validation error explaining that an integer was expected, instead of a confusing failure somewhere deep inside security.py later on.

Tips & Instructions for Developers

  • ✅ Always add .env to .gitignore before your first commit — not after. If a secret is ever committed, even once, treat it as compromised and rotate it, since Git history keeps old commits around indefinitely.
  • ✅ Always commit a .env.example alongside your code — it’s how the next developer (including future you, six months from now) knows what configuration the project actually needs.
  • ✅ Prefer pydantic-settings with typed fields and no defaults for anything genuinely sensitive — let missing configuration fail loudly at startup, not silently at runtime.
  • ✅ Generate real secret keys with a proper random generator (openssl rand -hex 32), never a phrase you typed yourself or copied from a tutorial.
  • ✅ Use different .env files (or different values entirely) for development, staging, and production — never point your local development environment at a real production database.
  • ❌ Don’t hardcode any default value for a secret field in Settings — a “convenient” default is exactly how secrets accidentally end up shipped in source code.
  • ❌ Don’t paste real secrets into chat messages, screenshots, tutorials, or Slack — treat them with the same care as a password, because that’s exactly what they are.

Official FastAPI Documentation References


Theory Questions

  1. Why should .env never be committed to version control, while .env.example should?
  2. What does load_dotenv() actually do, mechanically, to your running Python process?
  3. Why does giving a Settings field no default value (instead of a hardcoded fallback) improve your application’s security?
  4. What’s the practical benefit of pydantic-settings automatically converting .env string values into the correct Python types?
  5. Why is “fail immediately on startup” a better behavior for missing configuration than “start anyway, with a broken value”?

Practical Assignment

  1. Add an environment flag: Starting from Program 2’s project, add an environment: str field (values like "development" or "production") to Settings, read from a new ENVIRONMENT variable in .env, and use it to print a startup message indicating which environment the app thinks it’s running in.
  2. Simulate a leaked secret: Starting from Program 2’s project, imagine your .env file was accidentally committed and pushed to a public repository. Write out, step by step, exactly what you would need to do to fully remediate this (hint: it’s more than just deleting the file and committing again).
  3. Multiple environment files: Research (and briefly summarize in your own words) how pydantic-settings can be configured to load different .env files depending on context — for example, .env.development versus .env.production — and note one real-world scenario where this would matter for a team.

Summary

In this chapter, you learned:

  • Why hardcoded secrets are a serious, avoidable risk — and finally fixed every “Chapter 17 will cover this” placeholder from earlier in the series
  • How python-dotenv loads configuration from a .env file into your environment
  • How pydantic-settings builds on that same idea, adding automatic type conversion and validation, with zero manual os.getenv calls
  • The .env / .env.example / .gitignore pattern real teams use to keep secrets out of source control while still documenting what configuration is needed
Scroll to Top