Build a Production-Structured API
Structure a FastAPI app the way professional engineering teams do.
Introduction
30 Second Summary
At real companies, a new engineer's first task is never greenfield. They open an existing codebase and need to find the right file in seconds. The structure of a project matters just as much as the code inside it.
In this project, you will build a Document Management API using FastAPI, organized with the same production patterns that professional engineering teams use every day. You will manage dependencies with a lockfile, separate configuration from code, and push a clean repository to GitHub.
What You'll Build
You will open interactive API docs in the browser, create a document with a title and content, and watch the JSON response come back instantly with a generated ID and timestamp.
By the end of this project, you'll have:
- Interactive API docs at /docs where you can create, list, retrieve, and delete documents live in the browser.
- Environment-based configuration that separates secrets from code, type-validated at startup so you never deploy with a missing setting.
- A GitHub repository with locked dependencies that anyone can clone and run with a single command.
- Secret Mission: An automated test suite with pytest that validates every endpoint in seconds.
Are there any prerequisites?
This project assumes basic Python knowledge, familiarity with FastAPI routes and request/response patterns, and comfort with basic Git commands (init, commit, push). You should have Python 3.10+, VS Code, and a GitHub account ready.
Before We Start
You are about to build the foundation that every subsequent phase of your Enterprise Knowledge Assistant builds on.
Before writing any code, take a moment to commit to what you are building and why it matters for your career.
Set Up Your Project with uv
Professional Python projects need reproducible builds. That means every developer on a team installs the exact same dependency versions, no matter when they clone the repo.
To achieve this, you need a dependency manager that produces a lockfile. In this step, you will install uv, a modern Python project manager that replaces pip, virtualenv, and poetry in a single tool. Then you will scaffold your project and run a minimal FastAPI server.
In this step, get ready to:
- Install uv, the dependency manager recommended by FastAPI's own documentation.
- Create a new Python project and add FastAPI as a dependency.
- Write a health endpoint and confirm the dev server starts.
Install uv
uv is a Python project manager built by Astral. It handles virtual environments, dependency resolution, and lockfiles in one tool that is 10-100x faster than pip.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Install uv by running the installer for your operating system:
macOS
curl -LsSf https://astral.sh/uv/install.sh | sh
- After the install finishes, close and reopen your terminal so uv is on your PATH.
Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
- After the install finishes, close and reopen PowerShell so uv is on your PATH.
- Confirm the installation worked by running:
uv --version
✔️ I see a version number
uv is installed and ready. Move on to the next substep.
ⓧ Command not found
Your terminal cannot find the uv binary. This usually means the PATH was not updated.
- Make sure you fully closed and reopened your terminal after installing.
- On macOS, check that ~/.local/bin is in your PATH by running echo $PATH.
- Try running the install command again from the platform tab above.
What is uv?
uv is a single binary that replaces pip, virtualenv, and poetry. It creates a virtual environment automatically, resolves dependencies, and writes a uv.lock file that pins exact versions.
FastAPI's own documentation recommends uv for project management.
Create the project and add FastAPI
With uv installed, you can scaffold a new project. The --bare flag creates only a pyproject.toml without extra sample files you do not need yet.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running:
uv init enterprise-knowledge-assistant --bare
- Move into the new project folder by running:
cd enterprise-knowledge-assistant
- Confirm the project was created by running:
ls
You should see pyproject.toml listed in the output.
- Add FastAPI with its standard extras as a dependency by running:
uv add "fastapi[standard]>=0.141.0,<0.142.0"
What just happened?
uv did three things in that single command: it created a .venv virtual environment, resolved all of FastAPI's dependencies, and wrote the exact versions to a uv.lock file.
The version constraint >=0.141.0,<0.142.0 pins FastAPI to a specific minor release for stability. This is the recommended pattern from FastAPI's deployment docs.
- Now update pyproject.toml to add a project description. Open the file in VS Code by running:
code pyproject.toml
- Find the line that says version = "0.1.0" and add a description field directly below it.
- Also update requires-python to ">=3.10" if uv set a different value.
- Your file should look like this:
[project]
name = "enterprise-knowledge-assistant"
version = "0.1.0"
description = "Enterprise Knowledge Assistant - Phase 0"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.141.0,<0.142.0",
]
What does this file do?
pyproject.toml is the standard Python project manifest. It declares your project name, Python version requirement, and all dependencies.
Anyone who clones this repo can run uv sync to install the exact same packages you have.
- Save pyproject.toml with Cmd+S (macOS) or Ctrl+S (Windows).
Seeing a resolver error from uv add?
If uv add fails with a resolution error, make sure your requires-python is set to >=3.10 and that your installed Python version meets that requirement.
help me fix a uv dependency resolution error for FastAPI
Create a health endpoint and run the dev server
Every production API has a health endpoint. It is the simplest route you can write, and it proves your server starts correctly.
- Create a new file called main.py in the enterprise-knowledge-assistant folder by running:
touch main.py
- Open main.py in VS Code by running:
code main.py
- Copy the following code into main.py:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# Health check endpoint to verify the server is running
@app.get("/health", tags=["health"])
def health_check() -> dict:
return {"status": "healthy"}
What does this code do?
FastAPI() creates the application object that handles incoming HTTP requests.
The @app.get("/health") decorator registers a GET route at the path /health. When someone visits that URL, FastAPI calls health_check() and returns the dictionary as JSON.
- Save main.py with Cmd+S (macOS) or Ctrl+S (Windows).
Before you run the server, what do you think you will see in the terminal when FastAPI starts successfully?
- Start the FastAPI development server by running:
uv run fastapi dev
What does this command do?
uv run ensures the virtual environment is synced with your lockfile, then executes fastapi dev inside it. The dev subcommand starts the server with auto-reload on 127.0.0.1:8000.
This command takes over your terminal. You will need to press Ctrl+C to stop it later.
You should see output ending with Application startup complete and the server listening at http://127.0.0.1:8000.
- Open your browser and visit http://127.0.0.1:8000/health.
You should see the JSON response {"status":"healthy"} in your browser.
Not seeing the server start?
Make sure you saved main.py before running the command.
If you see a module-not-found error, your virtual environment may not have synced. Stop the server with Ctrl+C, run uv sync, then try uv run fastapi dev again.
help me get my FastAPI dev server to start with uv
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is what main.py should look like:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health", tags=["health"])
def health_check() -> dict:
return {"status": "healthy"}
And here is what pyproject.toml should look like:
[project]
name = "enterprise-knowledge-assistant"
version = "0.1.0"
description = "Enterprise Knowledge Assistant - Phase 0"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.141.0,<0.142.0",
]
Your project skeleton is alive. You have a running API with a health endpoint, managed dependencies, and a lockfile. Next up, you will build the actual document endpoints that turn this skeleton into a useful API.
Build a Flat Document API
Your dev server is running and the health endpoint proves the wiring works. Now it is time to build the actual Document API that stores and retrieves documents.
You will write everything in a single main.py file. This gets you to a working result fast, but as the file grows you will start to feel how crowded a single-file API gets. That discomfort is deliberate. It sets up the refactoring you will do in the next step.
In this step, get ready to:
- Define Pydantic schemas that validate incoming and outgoing document data.
- Add CRUD endpoints with in-memory storage.
- Test all operations through FastAPI's interactive docs at /docs.
Define Pydantic schemas for documents
Pydantic schemas define the shape of data your API accepts and returns. They validate input automatically and generate the documentation you see at /docs.
- Open main.py in VS Code.
- Select all the existing code and replace it with the following:
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
# Store documents in memory (keyed by UUID)
documents_db: dict[UUID, dict] = {}
class DocumentCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
source: str | None = None
class DocumentResponse(BaseModel):
id: UUID
title: str
content: str
source: str | None
created_at: datetime
class DocumentListResponse(BaseModel):
documents: list[DocumentResponse]
total: int
What does this code do?
- documents_db is a plain dictionary that stores documents in memory. Data disappears when the server restarts. This is fine for development.
- DocumentCreate defines what a client must send to create a document. Field(min_length=1) rejects empty strings automatically.
- DocumentResponse defines what the API sends back. It includes an id and created_at that the server generates.
- DocumentListResponse wraps a list of documents with a total count. This pattern makes pagination easier to add later.
Add the create endpoint
With schemas defined, you can write route functions that use them. The first endpoint lets clients create a new document.
- Add the following code below the DocumentListResponse class in main.py:
@app.get("/health", tags=["health"])
def health_check() -> dict:
return {"status": "healthy"}
@app.post("/api/v1/documents/", response_model=DocumentResponse, status_code=201)
def create_document(document: DocumentCreate) -> DocumentResponse:
# Generate a unique ID and timestamp for the new document
doc_id = uuid4()
doc = DocumentResponse(
id=doc_id,
title=document.title,
content=document.content,
source=document.source,
created_at=datetime.now(timezone.utc),
)
# Save to in-memory storage
documents_db[doc_id] = doc
return doc
What does this code do?
- The /health endpoint is the same one you had before. It stays at the top of your routes.
- response_model=DocumentResponse tells FastAPI to serialize the return value using that schema. It strips any extra fields and generates the docs.
- status_code=201 returns HTTP 201 Created instead of the default 200. This is the standard response code for resource creation.
- uuid4() generates a unique identifier for each document so you can retrieve or delete it later.
- Save main.py.
- Check the terminal where your dev server is running. It should auto-reload and print Application startup complete with no errors.
Seeing an error in the terminal?
- Check that the from pydantic import BaseModel, Field import is present. Without it, the schema classes have no base to inherit from.
- Make sure documents_db appears above any route that references it. Python reads top to bottom.
- help me debug a FastAPI startup error in my document API
Open http://127.0.0.1:8000/docs in your browser. You should see the POST /api/v1/documents/ endpoint listed alongside your health check.
Add the remaining CRUD endpoints
You have a way to create documents. Now you need endpoints to list them, retrieve one by ID, and delete one.
- Add the following code below the create_document function in main.py:
@app.get("/api/v1/documents/", response_model=DocumentListResponse)
def list_documents() -> DocumentListResponse:
# Return all stored documents with a count
docs = list(documents_db.values())
return DocumentListResponse(documents=docs, total=len(docs))
@app.get("/api/v1/documents/{document_id}", response_model=DocumentResponse)
def get_document(document_id: UUID) -> DocumentResponse:
# Return 404 if the document doesn't exist
if document_id not in documents_db:
raise HTTPException(status_code=404, detail="Document not found")
return documents_db[document_id]
@app.delete("/api/v1/documents/{document_id}", status_code=204)
def delete_document(document_id: UUID) -> None:
# Return 404 if the document doesn't exist
if document_id not in documents_db:
raise HTTPException(status_code=404, detail="Document not found")
del documents_db[document_id]
What does this code do?
- list_documents returns every document in the dictionary wrapped in a DocumentListResponse with a total count.
- get_document looks up a single document by UUID. If the ID does not exist, it raises a 404 error that FastAPI converts to a JSON response.
- delete_document removes a document from the dictionary. The status_code=204 means "No Content" and tells the client the deletion succeeded without returning a body.
- The {document_id} path parameter is automatically parsed as a UUID type. FastAPI rejects anything that is not a valid UUID with a 422 error.
- Save main.py.
- Confirm the terminal shows a clean reload with no errors.
Server crashing on reload?
- Check that HTTPException is included in the FastAPI import at the top of the file: from fastapi import FastAPI, HTTPException.
- Make sure UUID is imported from the uuid module. It is used as the type for document_id in two route functions.
- help me fix a crash in my FastAPI document endpoints
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.py and the dev server has reloaded without errors.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order. Scroll up and confirm each is present:
- Imports from datetime, uuid, fastapi, and pydantic.
- app = FastAPI() and documents_db dictionary.
- Three schema classes: DocumentCreate, DocumentResponse, DocumentListResponse.
- The /health endpoint.
- Four CRUD endpoints: create_document, list_documents, get_document, delete_document.
Test all four operations through the interactive docs
FastAPI generates interactive documentation at /docs where you can test every endpoint without writing a single line of client code.
Before you test, predict: if you create a document and then list all documents, how many should the list return?
- Open http://127.0.0.1:8000/docs in your browser.
- Click the POST /api/v1/documents/ endpoint to expand it.
- Click Try it out.
- Replace the example JSON in the request body with:
{
"title": "Project Kickoff Notes",
"content": "Discussed timeline and deliverables for Phase 1.",
"source": "meeting"
}
- Click Execute.
- You should see a 201 response with a JSON body containing your document plus an id and created_at field.
- Copy the id value from the response.
- Expand the GET /api/v1/documents/{document_id} endpoint.
- Click Try it out, paste the id into the document_id field, and click Execute.
- You should see a 200 response with the same document data.
What should I see?
The GET response body is identical to what POST returned. This confirms the document was stored successfully and can be retrieved by ID.
If you expand the GET /api/v1/documents/ endpoint (no ID) and execute it, the total field should be 1. That answers the prediction. One document in, one document listed.
- Expand the DELETE /api/v1/documents/{document_id} endpoint.
- Click Try it out, paste the same id, and click Execute.
- You should see a 204 response with no body.
- Try the GET by ID again with the same id. You should now see a 404 response with "Document not found".
Not seeing your endpoints at /docs?
- Make sure the URL is http://127.0.0.1:8000/docs (not /doc or /swagger).
- Check the terminal for errors. If the server failed to reload, the old endpoints (or nothing) will show.
- If you see only the health endpoint, confirm you saved the file after adding the CRUD functions.
- help me troubleshoot why my document endpoints don't appear in FastAPI docs
Your flat Document API is fully functional. You can create, list, retrieve, and delete documents through a clean REST interface. Next up, you will refactor this single file into a production-grade project structure that separates concerns into their own modules.
Refactor into Production Structure
Your Document API works. You can create, list, retrieve, and delete documents through the interactive docs at /docs.
But everything lives in one file. Schemas, routes, and the app instance are all competing for space in main.py. At a company like Theodo or Capgemini, a new engineer needs to find the right file in seconds. Separating routers, schemas, and config into their own modules is the standard pattern that makes codebases navigable and testable as they grow.
In this step, get ready to:
- Create a production directory layout with separate packages for routers, schemas, and config.
- Move your schemas and routes into focused modules, wired together with include_router.
- Configure the FastAPI CLI entrypoint so the dev server finds your app automatically.
Create the production directory layout
Production Python projects use packages (directories with __init__.py files) to organize related code. You will create an app/ package with sub-packages for routers, schemas, and core configuration.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Install pydantic-settings (you will use this for type-validated configuration) by running:
uv add "pydantic-settings>=2.14.0,<3.0.0"
Why install this now?
Your new app/core/config.py module will use pydantic-settings to load configuration. Installing it before creating the directory layout means uv.lock is already updated when you start writing code.
- Create the directory structure for your production layout by running:
macOS
mkdir -p app/routers app/schemas app/core
- Create the empty __init__.py files that mark each directory as a Python package by running:
touch app/__init__.py app/routers/__init__.py app/schemas/__init__.py app/core/__init__.py
Windows
mkdir app\routers, app\schemas, app\core
- Create the empty __init__.py files that mark each directory as a Python package by running:
ni app\__init__.py, app\routers\__init__.py, app\schemas\__init__.py, app\core\__init__.py -ItemType File
- Confirm the structure exists by running:
ls app
You should see the four sub-items listed: __init__.py, core, routers, and schemas.
Not seeing the directories?
Make sure you ran the commands from your project root (the enterprise-knowledge-assistant folder that contains pyproject.toml). Run ls pyproject.toml to confirm you are in the right place.
help me create my app directory structure
Move schemas and routes into separate modules
Now you will move code out of the root main.py into focused files. Each module gets one responsibility: schemas define data shapes, the router handles HTTP endpoints, and config holds application settings.
- Open app/schemas/documents.py in VS Code and paste the following code:
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class DocumentCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
source: str | None = None
class DocumentResponse(BaseModel):
id: UUID
title: str
content: str
source: str | None
created_at: datetime
class DocumentListResponse(BaseModel):
documents: list[DocumentResponse]
total: int
What does this code do?
- DocumentCreate defines what data a client sends when creating a document (title, content, optional source).
- DocumentResponse defines what the API returns, including the generated id and created_at timestamp.
- DocumentListResponse wraps a list of documents with a total count for pagination.
- Save app/schemas/documents.py.
- Verify the schemas import correctly by running:
uv run python -c "from app.schemas.documents import DocumentCreate; print('Schemas OK')"
You should see Schemas OK printed in the terminal.
Seeing an ImportError?
Check that app/__init__.py and app/schemas/__init__.py both exist. Without these files, Python does not recognize the directories as packages.
help me fix my schema import error
- Open app/core/config.py in VS Code and paste the following code:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Enterprise Knowledge Assistant"
app_version: str = "0.1.0"
debug: bool = False
settings = Settings()
What does this code do?
The Settings class uses pydantic-settings to read configuration from environment variables or a .env file. Each field has a default value, but can be overridden by setting the corresponding environment variable (like APP_NAME or DEBUG).
The settings instance at the bottom is a module-level singleton. Every file that imports it gets the same validated configuration object.
- Save app/core/config.py.
- Verify the config loads correctly by running:
uv run python -c "from app.core.config import settings; print(settings.app_name)"
You should see Enterprise Knowledge Assistant printed in the terminal.
- Open app/routers/documents.py in VS Code and paste the first section (imports, router setup, and the create endpoint):
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from app.schemas.documents import DocumentCreate, DocumentListResponse, DocumentResponse
router = APIRouter(prefix="/api/v1/documents", tags=["documents"])
documents_db: dict[UUID, DocumentResponse] = {}
@router.post("/", response_model=DocumentResponse, status_code=201)
def create_document(document: DocumentCreate) -> DocumentResponse:
doc_id = uuid4()
doc = DocumentResponse(
id=doc_id,
title=document.title,
content=document.content,
source=document.source,
created_at=datetime.now(timezone.utc),
)
documents_db[doc_id] = doc
return doc
What changed from the flat file?
- APIRouter replaces FastAPI() for defining routes. The router sets a prefix of /api/v1/documents so each endpoint path becomes relative (just / instead of the full URL).
- Schemas are imported from app.schemas.documents instead of being defined in the same file.
- The decorators use @router instead of @app.
- Add the remaining three endpoints below the create_document function in the same file:
@router.get("/", response_model=DocumentListResponse)
def list_documents() -> DocumentListResponse:
docs = list(documents_db.values())
return DocumentListResponse(documents=docs, total=len(docs))
@router.get("/{document_id}", response_model=DocumentResponse)
def get_document(document_id: UUID) -> DocumentResponse:
if document_id not in documents_db:
raise HTTPException(status_code=404, detail="Document not found")
return documents_db[document_id]
@router.delete("/{document_id}", status_code=204)
def delete_document(document_id: UUID) -> None:
if document_id not in documents_db:
raise HTTPException(status_code=404, detail="Document not found")
del documents_db[document_id]
- Save app/routers/documents.py.
- Verify the router module loads correctly by running:
uv run python -c "from app.routers.documents import router; print('Router OK')"
You should see Router OK printed.
- Open app/main.py in VS Code and paste the following code to wire everything together:
from fastapi import FastAPI
from app.core.config import settings
from app.routers import documents
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
debug=settings.debug,
)
app.include_router(documents.router)
@app.get("/health", tags=["health"])
def health_check() -> dict:
return {
"status": "healthy",
"app": settings.app_name,
"version": settings.app_version,
}
What does this code do?
- The FastAPI() instance reads its title and version from settings, making them configurable without code changes.
- app.include_router(documents.router) registers all the document endpoints onto the main app. The router's prefix means they still live at /api/v1/documents/.
- The /health endpoint now returns the app name and version from config, making it useful for monitoring.
- Save app/main.py.
- Delete the old root-level main.py file since all code now lives in the app/ package. Run:
macOS
rm main.py
Windows
Remove-Item main.py
Seeing a ModuleNotFoundError?
If any of the import verifications failed, double-check that app/routers/documents.py imports from app.schemas.documents (not just schemas.documents). All internal imports must use the full package path starting with app..
help me fix my module imports after refactoring
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files inside the app/ directory before moving on.
ⓧ I'd like to double check the full code
Your project should now contain these files. Scroll up and confirm each chunk is present in the correct file:
- app/schemas/documents.py contains the three Pydantic model classes.
- app/core/config.py contains the Settings class and settings instance.
- app/routers/documents.py contains the APIRouter with all four CRUD endpoint functions.
- app/main.py wires the router and config into the FastAPI instance with include_router.
Configure the FastAPI entrypoint and verify
The fastapi dev command looks for a file called main.py by default. Since your app now lives inside the app/ package, you need to tell the CLI where to find it.
- Open pyproject.toml in VS Code.
- Add the following section at the end of the file, below the existing [project] block:
[tool.fastapi]
entrypoint = "app.main:app"
What does this do?
The entrypoint value tells the fastapi CLI to import your app from app.main:app. Without this, you would need to type uv run fastapi dev app/main.py every time you start the server.
- Save pyproject.toml.
Before you run the server, what do you expect to see? Think about whether the API should behave any differently now that the code lives in separate files.
- Start the dev server by running:
uv run fastapi dev
You should see "Application startup complete" in the terminal, with the server running at http://127.0.0.1:8000.
- Open http://127.0.0.1:8000/docs in your browser.
You should see the interactive documentation with all four document endpoints (POST, GET list, GET by ID, DELETE) plus the health check. The page title now shows "Enterprise Knowledge Assistant" instead of the default "FastAPI" heading.
Why does the title look different?
Before, FastAPI() had no title argument, so /docs showed the default "FastAPI" heading. Now the app pulls its title from settings.app_name, so you see "Enterprise Knowledge Assistant" at the top of the docs page.
Server not starting?
Check that pyproject.toml has [tool.fastapi] on its own line followed by entrypoint = "app.main:app" on the next line. Make sure you deleted the old root-level main.py file so it does not conflict.
help me fix my FastAPI entrypoint configuration
Your API is now structured exactly like production codebases at consulting firms. Next up, you will add environment-based configuration with a .env file and push the whole project to GitHub.
Add Environment Configuration and Push to GitHub
Your API now lives in a clean production layout with separate modules for routing, schemas, and configuration.
One problem remains. The configuration module reads from a .env file that does not exist yet. Hardcoded defaults work fine in development, but production apps need settings that live outside the code so the same build runs in dev, staging, and production without changes. In this step you will create that file, protect it from version control, and push your project to GitHub with a lockfile that guarantees reproducible installs.
In this step, get ready to:
- Wire environment configuration through a .env file and verify it loads at startup.
- Create a professional .gitignore and initialize a Git repository with a meaningful commit.
- Push to GitHub with the lockfile committed for reproducible installs.
Create the .env file and verify configuration loads
Your app/core/config.py already defines a Settings class that reads from a .env file using pydantic-settings. Right now that file does not exist, so pydantic falls back to the defaults in the class. Creating the file makes your configuration explicit and editable without touching code.
- Create a file called .env in the project root (the same folder that contains pyproject.toml) with this content:
APP_NAME=Enterprise Knowledge Assistant
APP_VERSION=0.1.0
DEBUG=true
What does this file do?
Each line is a key-value pair that pydantic-settings loads into your Settings class at startup. The field names are case-insensitive, so APP_NAME maps to app_name on the class.
Because pydantic validates types, if you put a non-boolean value for DEBUG the app will refuse to start. This fail-fast behaviour catches misconfiguration before it reaches production.
- Save .env.
- If your dev server is still running, stop it with Ctrl+C in the terminal.
- Start the server again by running:
uv run fastapi dev
- Open your browser to http://127.0.0.1:8000/health.
You should see a JSON response containing "app": "Enterprise Knowledge Assistant" and "version": "0.1.0". These values come from your .env file, loaded through pydantic-settings into the Settings class.
Server won't start?
- Make sure .env is in the project root next to pyproject.toml. If it is inside app/, pydantic-settings will not find it.
- Check that each line uses KEY=value format with no spaces around the =.
- If you see a validation error mentioning DEBUG, make sure its value is true or false (no quotes).
Why can't my FastAPI app find my .env file?
Now test that changing the file actually changes behaviour.
- Open .env and change DEBUG=true to DEBUG=false.
- Save the file, stop the server with Ctrl+C, and restart it:
uv run fastapi dev
The server starts without error, proving pydantic-settings read and validated the new value. The /health endpoint still responds with the same app name and version because those did not change.
- Change DEBUG back to true in .env and save.
Why separate configuration from code?
The same Docker image you build in a later phase will run in dev, staging, and production. The only difference between environments is the .env file (or injected environment variables). This pattern is called the Twelve-Factor App methodology, and it is how every consulting firm deploys services.
Create a .gitignore and initialize the repository
Before committing your project, you need to tell Git which files to ignore. The .env file contains environment-specific values that must never reach version control. The .venv/ folder and __pycache__/ are machine-generated and should not be shared.
- Create a file called .gitignore in the project root with this content:
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environment
.venv/
# Environment variables
.env
# IDE
.vscode/
.idea/
# Distribution
dist/
build/
*.egg-info/
What does this file do?
Every path listed in .gitignore is invisible to git add. Your .env (secrets and environment config), .venv/ (hundreds of megabytes of packages), and __pycache__/ (compiled bytecode) will never be committed.
Collaborators still need to know which environment variables to set. That is what .env.example is for. It documents the required variables with safe default values.
- Create a file called .env.example in the project root:
APP_NAME=Enterprise Knowledge Assistant
APP_VERSION=0.1.0
DEBUG=false
Why .env.example instead of .env?
The .env.example file IS committed to the repository. It tells anyone who clones the project exactly which variables they need without exposing real values. Notice DEBUG=false here. Production defaults are always the safest choice in the example file.
- Save both files.
- Stop the dev server with Ctrl+C so the terminal is free for git commands.
- Initialize the Git repository by running:
git init
- Stage all files and create your first commit by running:
git add .
git commit -m "feat: production-structured document API with env config"
You should see a summary listing the files committed. Notice that .env is not in the list because .gitignore excluded it.
- Verify .env was excluded by running:
git status
You should see nothing to commit, working tree clean. If .env appeared as untracked, your .gitignore is misconfigured.
Seeing .env in git status?
- Make sure your .gitignore contains the line .env (not *.env or .env/).
- If you already committed .env before creating .gitignore, run git rm --cached .env then commit again.
Why is .env still showing up in my git status?
Push to GitHub with the lockfile
Your project includes a uv.lock file that pins the exact version of every dependency. Committing this file means anyone who clones your repo will install the same packages you used, byte for byte.
- Open GitHub in your browser and sign in to your account.
- Click the + icon in the top-right corner, then click New repository.
- Enter enterprise-knowledge-assistant as the repository name.
- Leave it set to Public.
- Do NOT check any boxes for README, .gitignore, or license (you already have these locally).
- Click Create repository.
GitHub shows you a page with setup commands. You will use the "push an existing repository" instructions.
- Return to your terminal and connect your local repo to GitHub by running:
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/enterprise-knowledge-assistant.git"]]
- Push your commit by running:
git push -u origin main
Push rejected or branch name mismatch?
- If Git says the branch is master instead of main, rename it first with git branch -M main, then push again.
- If you see an authentication error, make sure you are signed in to GitHub in your terminal. GitHub requires a personal access token or SSH key for push access.
Why is my git push failing with an authentication error?
Before you check your repository, predict: will you see the .env file listed in the GitHub file tree?
- Open your repository URL in the browser (https://github.com/YOUR-USERNAME/enterprise-knowledge-assistant).
You should see all your project files including uv.lock, .gitignore, .env.example, and the app/ folder. The .env file is nowhere in sight. That is exactly what .gitignore accomplished.
Why commit uv.lock?
The lockfile records the exact resolved version of every dependency and sub-dependency. When a colleague clones your repo and runs uv sync, they get the identical environment you developed against. Without it, a minor patch release could introduce a breaking change between your machine and theirs.
Your production-structured API now lives in a proper Git repository with environment configuration, a lockfile for reproducibility, and secrets safely excluded. Anyone can clone it, run uv sync followed by uv run fastapi dev, and have the full API running in seconds.
Secret mission
Add Automated Tests
Code without tests cannot be refactored safely. Every subsequent phase of your Enterprise Knowledge Assistant will change this codebase. Tests are what prevent regressions. In this secret mission, you will write your first API test suite using pytest and FastAPI's built-in TestClient.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project directory (enterprise-knowledge-assistant on your Desktop).
- GitHub repository (enterprise-knowledge-assistant remote repository).
Keep everything running
No action needed. Choose this if you are still actively building on this project or plan to continue with the next phase.
- Your project is on GitHub and runs locally with uv run fastapi dev. Nothing is costing you money.
- The uv tool remains installed on your machine for future Python projects.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and repository stay intact for when you return.
- If the FastAPI dev server is still running in your terminal, press Ctrl+C to stop it.
- Your project files remain in the enterprise-knowledge-assistant folder on your Desktop.
- Your GitHub repository stays available to clone from any machine.
Delete - I don't want to use this again
Remove all project resources and start fresh. You can always re-clone from GitHub if you change your mind.
- Delete the local project directory by running this command in your terminal:
macOS
cd ~/Desktop
rm -rf enterprise-knowledge-assistant
Windows
cd ~\Desktop
Remove-Item -Recurse -Force enterprise-knowledge-assistant
- Delete the GitHub repository:
- Go to your repository page on GitHub.
- Click Settings (the tab at the top of the repository page).
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
You just built a production-structured Document Management API from scratch using FastAPI, uv, and pydantic-settings. This is the exact project layout that engineering teams at consulting firms use to organize codebases that multiple developers can navigate and extend.
You've learned how to:
- Build a production-structured FastAPI application with separated concerns across routers, schemas, and config modules that scale as the project grows.
- Manage dependencies with uv and a committed lockfile for reproducible installs across any machine.
- Configure your application through environment variables using pydantic-settings, and push a professional repository to GitHub with a proper .gitignore.
- Secret Mission: Added automated API tests with pytest and FastAPI's TestClient that validate every endpoint.
Ready to quiz yourself?