Dockerize Your Knowledge Assistant

Containerize a FastAPI app with PostgreSQL using Docker Compose.

Introduction

30 Second Summary

Every developer has heard "it works on my machine." Your API runs perfectly on your computer, but handing it to someone else means they need to recreate your exact setup from scratch.

In this project, you will package your Enterprise Knowledge Assistant API into a Docker container. You will then add a PostgreSQL database alongside it using Docker Compose, so your documents persist even when containers are destroyed.

What You'll Build

You type docker compose up, watch your API and database start together, create a document through the interactive docs, tear the whole stack down, bring it back up, and see that your document is still there.

By the end of this project, you'll have:

  • A single command (docker compose up) that launches your entire API and database stack, ready to accept requests in seconds.
  • An API that waits for its database to be healthy before starting, so requests never hit a half-ready system.
  • Documents that persist across full teardowns. Destroy every container, rebuild from scratch, and your data is still there.
  • Secret Mission: Cut your container image size in half with a multi-stage Docker build.

Are there any prerequisites?

This project is Phase 1 of the Enterprise Knowledge Assistant series. You need the completed Phase 0 project (Build a Production-Structured API) with the app/ package structure, uv, and pydantic-settings already in place.

Before We Start

Before we dive in, let's lock in what you're about to build and why containerization matters for the Enterprise Knowledge Assistant.

Set Up Docker Desktop

Your API runs on your machine with your exact Python version and uv setup. If a teammate clones your repo, they need to match your environment perfectly or things break.

Docker solves this by packaging your app and all its dependencies into a container that runs identically anywhere. Docker Desktop gives you the Docker Engine on Windows, using WSL 2 as its backend. You already have WSL 2 enabled from your development environment setup.

In this step, get ready to:
  • Install Docker Desktop with the WSL 2 backend.
  • Verify that both the Docker CLI and Docker Compose plugin are working.
  • Confirm WSL 2 integration is enabled for your default distribution.
Download and install Docker Desktop

Docker Desktop is a one-click install that bundles the Docker Engine, CLI, and Docker Compose into a single application. The per-user installation mode requires no administrator privileges and automatically uses the WSL 2 backend.

  • Open the Docker Desktop download page in your browser:
  • https://docs.docker.com/desktop/setup/install/windows-install/
  • Click the download button to get Docker Desktop Installer.exe.
  • Double-click Docker Desktop Installer.exe to run the installer.
  • When the installer asks which installation mode you prefer, choose Per-user installation (recommended). This does not require administrator privileges.
  • On the Configuration page, ensure the Use WSL 2 instead of Hyper-V option is selected.
  • Follow the remaining prompts and click Close when the installation finishes.

What is per-user installation?

Per-user mode installs Docker Desktop without needing admin rights. It uses WSL 2 as the only backend, which is exactly what you want. The installer places files in your user profile directory, keeping the install lightweight and secure.

  • Start Docker Desktop after installation by pressing the Windows key, typing Docker Desktop, and pressing Enter.
  • Wait for the Docker icon in your system tray (bottom-right corner) to stop animating. A steady whale icon means Docker Engine is running and ready.

What should I see?

The Docker Desktop window opens showing the Containers view. The whale icon in your system tray becomes steady (not animating). This means the Docker Engine is running and ready to accept commands.

Docker Desktop not starting?

If Docker Desktop shows an error about WSL 2 not being enabled, open PowerShell and run wsl --version to confirm WSL 2 is installed. You need version 2.1.5 or later.

If the system tray icon keeps animating for more than two minutes, try restarting Docker Desktop from the system tray menu (right-click the whale icon and select Restart).

Help me troubleshoot Docker Desktop startup

Verify Docker Engine and Compose

Docker Desktop installs both the Docker CLI and the Compose plugin. You need a fresh terminal session to pick up the new PATH entries that the installer added.

  • Open a new PowerShell terminal in VS Code by pressing Ctrl+Shift+` (backtick). If you already have a terminal open, click the + icon in the terminal panel to create a new one.

Before you run the next command, what do you expect to see if Docker installed correctly?

  • Check the Docker CLI version by running:
docker --version
  • Check the Docker Compose plugin version by running:
docker compose version

✔️ I see version numbers for both commands

Both commands printed a version string. Docker Engine and Compose are installed and accessible from your terminal. You are ready to move on.

ⓧ Command not found or not recognized

Your terminal does not have the Docker CLI on its PATH. Try these fixes:

  • Make sure Docker Desktop is running (check the whale icon in the system tray).
  • Close all terminal windows in VS Code and open a fresh one with Ctrl+Shift+`. The installer updates PATH, but existing terminals do not pick up the change until you reopen them.
  • If the command still fails, restart your computer to complete the Docker Desktop installation and try again.

Help me fix docker command not found

Verify WSL 2 integration

Docker Desktop uses WSL 2 as its engine. The per-user install mode locks you into the WSL 2 backend automatically. You can confirm the integration is active and enabled for your default WSL distribution in Docker Desktop settings.

  • In Docker Desktop, click the gear icon (top-right) to open Settings.
  • Select Resources in the left sidebar.
  • Click WSL Integration.
  • Confirm that the toggle for your default distribution (typically Ubuntu) is enabled.

What does WSL Integration do?

When integration is enabled for a distribution, docker commands become available directly from that distribution's terminal. For this project you will run commands from PowerShell, but the integration ensures Docker Engine communicates with your WSL 2 kernel correctly.

  • Confirm your WSL version meets the minimum requirement by running this in your PowerShell terminal:
wsl --version

You should see a WSL version of 2.1.5 or higher in the output.

WSL Integration not showing?

If WSL Integration does not appear under Resources, Docker Desktop may be in Windows container mode. Right-click the Docker whale icon in your system tray and select Switch to Linux containers.

Help me find WSL Integration in Docker Desktop

Docker is ready on your machine. Next up, you will write a Dockerfile that packages your FastAPI app into a portable container image.

Containerize Your API

Docker is installed and ready. Now you'll use it to package your FastAPI API into a container image that anyone can run with a single command.

Right now, your API only works on your machine because it depends on your exact Python version and uv setup. A Dockerfile defines the recipe for building a portable image that bundles your code, dependencies, and runtime together.

In this step, get ready to:
  • Write a Dockerfile that packages your FastAPI app using uv.
  • Create a .dockerignore to keep the image lean and safe.
  • Build and run the container to confirm everything works.
Write the Dockerfile

A Dockerfile is a text file with instructions that Docker reads to build an image. Each line adds a layer: the base operating system, your tools, your code, and the command to start your app.

  • In VS Code, create a new file called Dockerfile in the root of your enterprise-knowledge-assistant project folder (the same level as pyproject.toml).
  • Paste the following code into the file:
FROM python:3.12-slim

COPY --from=ghcr.io/astral-sh/uv:0.12.2 /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-cache --no-install-project

COPY . /app
RUN uv sync --frozen --no-cache

CMD ["/app/.venv/bin/fastapi", "run", "app/main.py", "--port", "80", "--host", "0.0.0.0"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a minimal Python image as the base layer.
  • COPY --from=ghcr.io/astral-sh/uv:0.12.2 pulls the uv binary from its official container image so you can install dependencies fast.
  • COPY pyproject.toml uv.lock and RUN uv sync --frozen --no-cache --no-install-project install dependencies FIRST. Docker caches this layer, so rebuilds only take seconds when you change your code but not your dependencies.
  • COPY . /app adds your application code on top of the cached dependency layer.
  • CMD tells Docker what command to run when the container starts. Here it launches FastAPI on port 80 inside the container.
  • Save Dockerfile.
  • You should see Dockerfile appear in the Explorer sidebar of VS Code, at the same level as pyproject.toml.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

COPY --from=ghcr.io/astral-sh/uv:0.12.2 /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-cache --no-install-project

COPY . /app
RUN uv sync --frozen --no-cache

CMD ["/app/.venv/bin/fastapi", "run", "app/main.py", "--port", "80", "--host", "0.0.0.0"]

File not showing in the sidebar?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension. Windows may silently add .txt if you created it outside VS Code.
  • Confirm it lives at the project root (next to pyproject.toml), not inside the app/ folder.

Help me fix my Dockerfile location or naming

Create .dockerignore and build the image

A .dockerignore file tells Docker which files to skip when copying your project into the image. This keeps the image small and prevents secrets like your .env file from being baked in.

  • Create a new file called .dockerignore in the project root (same level as your Dockerfile).
  • Paste the following patterns into the file:
.venv/
.git/
__pycache__/
*.py[cod]
*$py.class
.env
.idea/
.vscode/
dist/
build/
*.egg-info/

What does this exclude?

  • .venv/ keeps your local virtual environment out. The container builds its own fresh .venv from uv.lock.
  • .env prevents secrets from being copied into the image.
  • .git/ and IDE folders (.idea/, .vscode/) are development-only files that add size without value.
  • Save .dockerignore.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before building.

ⓧ I'd like to double check the full code

.venv/
.git/
__pycache__/
*.py[cod]
*$py.class
.env
.idea/
.vscode/
dist/
build/
*.egg-info/

Now you have both files Docker needs. Time to build the image.

  • Build the Docker image by running this command in your PowerShell terminal (make sure you are inside the enterprise-knowledge-assistant folder):
docker build -t enterprise-knowledge-assistant .

Docker reads your Dockerfile line by line, downloads the base image, installs dependencies, and packages everything into a tagged image. This first build takes around 30-60 seconds because it downloads layers from the internet.

When the build finishes, you should see each step complete without errors. The final lines confirm the image was created and tagged.

Build failing with an error?

  • If you see a connection error, make sure Docker Desktop is running. Check for the whale icon in your system tray.
  • If you see a file-not-found error for pyproject.toml or uv.lock, confirm you ran the command from inside the enterprise-knowledge-assistant folder (where those files live).
  • If you see a syntax error mentioning the CMD line, check that the quotes in your Dockerfile are straight double quotes, not curly quotes.

Help me debug my Docker build error

Run the container and test

The image is built. Now you'll start a container from that image and confirm your API responds exactly as it does locally.

  • Start a container from your new image by running:
docker run -p 8000:80 enterprise-knowledge-assistant

What does the -p flag do?

The -p 8000:80 flag maps port 8000 on your machine to port 80 inside the container. Your Dockerfile told FastAPI to listen on port 80, so this bridge lets you reach it at http://127.0.0.1:8000 in your browser.

Your terminal now shows the Uvicorn startup logs and is occupied by the running container. Before you test the endpoint, what do you expect the /health endpoint to return?

  • Open your browser and visit http://127.0.0.1:8000/health.

You should see this JSON response:

{"status":"healthy","app":"Enterprise Knowledge Assistant","version":"0.1.0"}
  • Now visit http://127.0.0.1:8000/docs to confirm the interactive API docs load.

Your API is running inside a container. The same image you just built is what you would ship to a server.

  • Return to your PowerShell terminal and press Ctrl+C to stop the container.

Browser showing "unable to connect"?

  • Check that your terminal still shows the Uvicorn logs. If the container exited immediately, look for an import error in the output. A common cause is a typo in app/main.py that Python catches at startup.
  • If you see a port conflict error mentioning port 8000, you may have another process using that port. Stop any running fastapi dev sessions in other terminals, then try the docker run command again.
  • Make sure you're using http:// in the URL, not https://.

Help me figure out why my container isn't responding

Your API is fully containerized. Next up, you'll add a PostgreSQL database as a second container and orchestrate both with Docker Compose.

Add PostgreSQL with Docker Compose

Your API runs in a container now, which is a huge step forward. But there is a problem hiding in plain sight: every document you create lives in a Python dictionary inside the container. The moment that container stops, your data disappears.

The fix is running a second container with PostgreSQL alongside your app. Docker Compose orchestrates multi-container setups like this, handling networking between services and controlling startup order so the database is ready before your API tries to connect.

In this step, get ready to:
  • Define a compose.yaml with your FastAPI app and a PostgreSQL database as linked services.
  • Add the Python libraries that will eventually let your app talk to PostgreSQL.
  • Start both containers together and verify the database is healthy before the API launches.
Write compose.yaml with app and db services

A compose.yaml file declares all the services your project needs and how they relate to each other. Docker Compose reads this file and creates an isolated network where services communicate by name.

  • In VS Code, create a new file called compose.yaml in the root of your enterprise-knowledge-assistant project (the same folder that holds your Dockerfile).
  • Paste the following configuration into compose.yaml:
services:
  app:
    build: .
    ports:
      - "8000:80"
    environment:
      - DATABASE_URL=postgresql+psycopg://postgres:changeme@db:5432/knowledge_assistant
      - APP_NAME=Enterprise Knowledge Assistant
      - APP_VERSION=0.1.0
      - DEBUG=true
    # Wait for db to pass its health check before starting
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_DB=knowledge_assistant
    expose:
      - 5432
    # Verify Postgres is accepting connections every 10s
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

What does this configuration do?

  • app builds from your existing Dockerfile and maps host port 8000 to container port 80.
  • db pulls the official postgres:17 image and creates a database called knowledge_assistant.
  • depends_on with condition: service_healthy ensures the app container only starts after PostgreSQL passes its pg_isready health check.
  • The DATABASE_URL uses db as the hostname because Docker Compose creates a shared network where services refer to each other by name.
  • Save compose.yaml.
  • Confirm the file exists by running this in your terminal:
ls compose.yaml

You should see compose.yaml listed in the output.

Don't see compose.yaml?

  • Make sure you created the file in the project root (enterprise-knowledge-assistant/), not inside the app/ subfolder.
  • The filename must be exactly compose.yaml with no prefix. The older docker-compose.yml naming is deprecated.

Help me troubleshoot why my compose.yaml file is not appearing in ls output

Add SQLModel and psycopg dependencies

PostgreSQL will be running in a container, but your Python app needs libraries to talk to it. SQLModel is an ORM built by the same author as FastAPI, and psycopg is the modern PostgreSQL adapter for Python.

  • Add both libraries to your project by running:
uv add sqlmodel "psycopg[binary]"

You should see output showing both packages resolved and added to pyproject.toml. Your uv.lock file also updates automatically.

Why psycopg with the binary extra?

The [binary] extra bundles the compiled PostgreSQL client library so you do not need a C compiler during install. This keeps your Docker image build fast and simple.

Seeing a resolution error?

  • Make sure you are running the command from inside the enterprise-knowledge-assistant folder where pyproject.toml lives.
  • If you see a conflict with existing packages, try uv lock --upgrade first, then re-run the uv add command.

Help me fix a uv add resolution error when adding sqlmodel and psycopg

Start the Compose stack

Both pieces are in place: the compose file defines the services, and your dependencies are ready to be installed inside the image. Time to start the full stack.

  • If you still have a container running from Step 2, stop it by pressing Ctrl+C in that terminal.
  • Before you run the next command, predict: what do you think will happen when Docker builds the image and starts both services?
  • Start the Compose stack by running:
docker compose up --build

Watch the terminal output closely. You will see Docker build your app image (using the cached layers from earlier), then pull the postgres:17 image, and finally start both containers. The db container transitions to a healthy state before app begins accepting requests.

  • Open your browser and visit http://127.0.0.1:8000/docs.

You should see the interactive API docs page, confirming the app service is running and responding.

What about the database?

PostgreSQL is running and healthy alongside your app. Your API is not wired to it yet though. It still uses the in-memory dictionary from Phase 0. The next step connects them.

Port already in use?

  • If you see a port 8000 conflict, a container from Step 2 is likely still running. Run docker ps to list running containers, then docker stop <container_id> to stop it.
  • If the app container exits immediately, check the build output for Python import errors. These usually mean the uv.lock file was not updated after running uv add.

Help me fix a port conflict or container startup error with docker compose up

✔️ Awesome, I've got everything!

Great. Make sure you have saved compose.yaml and confirmed both services start with docker compose up --build.

ⓧ I'd like to double check the full code

Here is the complete compose.yaml for this step:

services:
  app:
    build: .
    ports:
      - "8000:80"
    environment:
      - DATABASE_URL=postgresql+psycopg://postgres:changeme@db:5432/knowledge_assistant
      - APP_NAME=Enterprise Knowledge Assistant
      - APP_VERSION=0.1.0
      - DEBUG=true
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_DB=knowledge_assistant
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

Your API now runs side-by-side with a real PostgreSQL database, orchestrated by a single command. Next up, you will wire your app to actually store documents in that database instead of the throwaway dictionary.

Wire Your API to PostgreSQL

PostgreSQL is running and healthy inside your Docker stack, but your API still stores documents in a Python dictionary. Every time the container restarts, that dictionary resets and your documents vanish.

Connecting your API to the running PostgreSQL container means documents survive restarts. You will use SQLModel, the ORM built by the same author as FastAPI, to define your database table and handle all queries.

In this step, get ready to:
  • Create the database connection module and update your app config to read the database URL.
  • Convert document schemas from Pydantic-only to a SQLModel table model with session-based CRUD.
  • Add a startup lifespan that creates tables, then verify documents persist across container restarts.
Create the database module and update config

Your app needs two things to talk to PostgreSQL: a connection URL (already set in compose.yaml) and a module that creates the database engine and provides sessions to each request.

  • Open app/core/config.py in VS Code and replace its contents with the following:
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
    database_url: str = "postgresql+psycopg://postgres:changeme@localhost:5432/knowledge_assistant"


settings = Settings()

What does this code do?

The new database_url field tells your app where PostgreSQL lives. The default points at localhost for local development, but inside Docker, the DATABASE_URL environment variable from compose.yaml overrides it to point at the db service.

The extra="ignore" setting tells pydantic-settings to silently skip any environment variables that do not match a field, preventing errors from unrelated env vars.

  • Save app/core/config.py.
  • Create a new file at app/core/database.py and paste the following:
from sqlmodel import Session, SQLModel, create_engine

from app.core.config import settings

engine = create_engine(settings.database_url, echo=settings.debug)


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session

What does this code do?

  • create_engine opens a connection pool to PostgreSQL using the URL from your config.
  • echo=settings.debug prints every SQL statement to the console when debug mode is on, so you can see exactly what queries run.
  • create_db_and_tables() runs CREATE TABLE for every SQLModel class registered as a table. You will call this once at app startup.
  • get_session() is a generator that yields one database session per request and closes it afterward. FastAPI injects it via Depends().
  • Save app/core/database.py.
  • Rebuild and start the stack to confirm these new files do not break anything by running:
docker compose up --build
  • Open http://127.0.0.1:8000/health in your browser. You should still see the JSON health response with "status":"healthy".

Build failing with an import error?

  • Check that database.py is inside app/core/ (not at the project root).
  • Make sure from app.core.config import settings matches your existing file path exactly.
  • help me debug a Docker build import error for my database module
  • Stop the containers with Ctrl+C in your terminal.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is app/core/config.py in full:

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
    database_url: str = "postgresql+psycopg://postgres:changeme@localhost:5432/knowledge_assistant"


settings = Settings()

And here is app/core/database.py in full:

from sqlmodel import Session, SQLModel, create_engine

from app.core.config import settings

engine = create_engine(settings.database_url, echo=settings.debug)


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session
Convert documents to SQLModel and update the router

Your existing document schemas are pure Pydantic models. To store documents in PostgreSQL, you need a SQLModel class marked with table=True that maps directly to a database table.

  • Open app/schemas/documents.py and replace its entire contents with this code:
from datetime import datetime, timezone
from uuid import UUID, uuid4

from pydantic import BaseModel
from pydantic import Field as PydanticField
from sqlmodel import Field, SQLModel


class Document(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    title: str = Field(max_length=200)
    content: str
    source: str | None = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class DocumentCreate(BaseModel):
    title: str = PydanticField(min_length=1, max_length=200)
    content: str = PydanticField(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 is table=True?

Adding table=True to a SQLModel class tells SQLModel this class represents an actual database table, not just a data shape. SQLModel generates the CREATE TABLE SQL from the field definitions.

The separate DocumentCreate and DocumentResponse classes use plain BaseModel because they define what the caller sends and what the API returns, not database tables.

  • Save app/schemas/documents.py.

Now update the router to use database sessions instead of the in-memory dictionary.

  • Open app/routers/documents.py and replace its entire contents with these imports and the create_document endpoint:
from datetime import datetime, timezone
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select

from app.core.database import get_session
from app.schemas.documents import (
    Document,
    DocumentCreate,
    DocumentListResponse,
    DocumentResponse,
)

router = APIRouter(prefix="/api/v1/documents", tags=["documents"])


@router.post("/", response_model=DocumentResponse, status_code=201)
def create_document(
    document: DocumentCreate, session: Session = Depends(get_session)
) -> DocumentResponse:
    db_doc = Document(
        title=document.title,
        content=document.content,
        source=document.source,
        created_at=datetime.now(timezone.utc),
    )
    session.add(db_doc)
    session.commit()
    session.refresh(db_doc)
    return db_doc

What changed from the old router?

  • Each endpoint now receives a session: Session = Depends(get_session) parameter. FastAPI calls get_session() automatically and injects the open database connection.
  • session.add() stages the new document, session.commit() writes it to PostgreSQL, and session.refresh() reloads the server-generated fields like id and created_at.
  • Add the remaining read and delete endpoints below create_document in the same file:

@router.get("/", response_model=DocumentListResponse)
def list_documents(session: Session = Depends(get_session)) -> DocumentListResponse:
    docs = session.exec(select(Document)).all()
    return DocumentListResponse(documents=docs, total=len(docs))


@router.get("/{document_id}", response_model=DocumentResponse)
def get_document(
    document_id: UUID, session: Session = Depends(get_session)
) -> DocumentResponse:
    doc = session.get(Document, document_id)
    if not doc:
        raise HTTPException(status_code=404, detail="Document not found")
    return doc


@router.delete("/{document_id}", status_code=204)
def delete_document(
    document_id: UUID, session: Session = Depends(get_session)
) -> None:
    doc = session.get(Document, document_id)
    if not doc:
        raise HTTPException(status_code=404, detail="Document not found")
    session.delete(doc)
    session.commit()

What do these endpoints do?

  • list_documents runs a SELECT query using select(Document) and returns all rows.
  • get_document fetches a single row by UUID primary key. If the row is missing, it returns a 404 error.
  • delete_document removes the row from PostgreSQL and commits the transaction.
  • Save app/routers/documents.py.
  • Rebuild and start the stack by running:
docker compose up --build

The containers should start and the app logs should show it is running on port 80.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand POST /api/v1/documents/ and click Try it out.
  • Click Execute.

You will see a 500 Internal Server Error. This is expected. The document table does not exist in PostgreSQL yet because nothing has called create_db_and_tables(). The next substep fixes this.

  • Stop the containers with Ctrl+C.

✔️ Awesome, I've got everything!

Great. Confirm both files are saved before continuing.

ⓧ I'd like to double check the full code

Here is app/schemas/documents.py in full:

from datetime import datetime, timezone
from uuid import UUID, uuid4

from pydantic import BaseModel
from pydantic import Field as PydanticField
from sqlmodel import Field, SQLModel


class Document(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    title: str = Field(max_length=200)
    content: str
    source: str | None = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class DocumentCreate(BaseModel):
    title: str = PydanticField(min_length=1, max_length=200)
    content: str = PydanticField(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

Scroll up and confirm these chunks are present in order in app/routers/documents.py:

  • Imports and router declaration.
  • The create_document endpoint.
  • The list_documents, get_document, and delete_document endpoints.
Update main.py and rebuild

The missing piece is calling create_db_and_tables() when the app starts. FastAPI's lifespan handler runs code once at startup before any requests arrive.

  • Open app/main.py and replace its entire contents with the following:
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.core.config import settings
from app.core.database import create_db_and_tables
from app.routers import documents


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield

What is a lifespan handler?

The @asynccontextmanager decorator turns this function into a context manager. Everything before yield runs once at startup. Everything after yield runs at shutdown.

Calling create_db_and_tables() here ensures the document table exists in PostgreSQL before any request hits the router.

  • Add the app creation and health check endpoint below the lifespan function in the same file:

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

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 lifespan=lifespan do?

Passing lifespan=lifespan to the FastAPI() constructor registers your startup handler. When the container starts, FastAPI calls create_db_and_tables() first, then begins accepting HTTP requests.

  • Save app/main.py.
  • Rebuild and start the full stack by running:
docker compose up --build

Watch the logs. You should see the db container pass its health check, then the app container start and log that it is running on port 80.

Before you test, predict: if you create a document via /docs and then restart only the app container, will the document still be there?

  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand POST /api/v1/documents/ and click Try it out.
  • Replace the example JSON body with:
{
  "title": "Docker persistence test",
  "content": "This document should survive a restart.",
  "source": "manual test"
}
  • Click Execute. You should see a 201 response with the document's generated id and created_at fields.

Now restart just the app container to prove the data lives in PostgreSQL, not in the app's memory.

  • Open a second PowerShell window and run:
docker compose restart app
  • Return to http://127.0.0.1:8000/docs in your browser (refresh the page if needed).
  • Expand GET /api/v1/documents/, click Try it out, then click Execute.

Your document is still there. The app container restarted (fresh Python process), but PostgreSQL kept the data safe in the db container.

Seeing a 500 error when creating a document?

  • Check your terminal logs for the exact error. If you see a message about the document relation not existing, your lifespan handler is not running. Confirm lifespan=lifespan is passed to FastAPI() in main.py.
  • If you see a connection refused error, make sure the DATABASE_URL in compose.yaml uses db as the host (not localhost). Inside Docker, db resolves to the PostgreSQL container.
  • help me debug a 500 error when creating a document in my Dockerized FastAPI app

✔️ Awesome, I've got everything!

Great. Make sure app/main.py is saved.

ⓧ I'd like to double check the full code

Here is app/main.py in full:

from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.core.config import settings
from app.core.database import create_db_and_tables
from app.routers import documents


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


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

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,
    }

Your API now stores documents in a real database. But there is one more gap. In the next step, you will discover what happens when you completely tear down the stack with docker compose down instead of just restarting.

Persist Data with Volumes

In the last step, you wired your API to PostgreSQL and proved that documents survive a container restart. But what happens when you tear the entire stack down and rebuild it from scratch?

Without a named volume, PostgreSQL stores its data inside the container's writable layer. When docker compose down removes the container, that layer is destroyed along with it. A named volume stores data on your host machine so it survives container removal.

In this step, get ready to:
  • Observe data loss when the database container is destroyed.
  • Add a named volume to persist PostgreSQL data across teardowns.
  • Verify documents survive a full docker compose down and docker compose up cycle.
Observe data loss

Right now your documents persist across docker compose restart app because a restart reuses the same container. But docker compose down removes the container entirely and creates a fresh one on the next up.

  • Make sure your stack is running by executing this command:
docker compose up --build -d
  • Open http://127.0.0.1:8000/docs in your browser.
  • Use the POST /api/v1/documents/ endpoint to create a test document with any title and content.
  • Use the GET /api/v1/documents/ endpoint to confirm the document appears in the list.

Your document is stored in PostgreSQL inside the db container. Before you run the next command, predict what will happen to that document when you destroy the container and create a new one.

  • Tear down the stack by running:
docker compose down

You will see output showing both containers being stopped and removed, along with the default network.

  • Bring the stack back up by running:
docker compose up --build -d
  • Once both services are healthy, open http://127.0.0.1:8000/docs again.
  • Use GET /api/v1/documents/ to list your documents.

The response shows an empty list: {"documents": [], "total": 0}. Your document is gone.

Why did the data disappear?

When you ran docker compose down, Docker removed the db container. PostgreSQL stored its data files inside that container's writable filesystem layer. When docker compose up ran again, it created a brand-new container with an empty database.

This is different from docker compose restart, which reuses the same container (keeping its filesystem intact). The fix is a named volume that stores data on your host machine, independent of any container's lifecycle.

Add a named volume

A named volume is a Docker-managed storage area on your host machine. You mount it into a container at a specific path, and the data at that path persists even after the container is removed.

  • Open compose.yaml in VS Code.
  • In the db service, find the expose: block. Add a volumes: key directly below it (before healthcheck:) with the following content:
    volumes:
      - db-data:/var/lib/postgresql/data

What does this volume mapping do?

The format is volume-name:/container/path. Here, db-data is the volume name and /var/lib/postgresql/data is where PostgreSQL stores its database files inside the container.

Docker maps that container path to a persistent area on your host. When the container is destroyed and recreated, the new container mounts the same volume and finds all the data still there.

  • At the bottom of compose.yaml, after the services: block ends, add a top-level volumes: section to declare the volume:
volumes:
  db-data:

Why declare the volume at the top level?

The top-level volumes: section tells Docker Compose to create and manage this volume. Without it, Compose would not know that db-data is a named volume (and would treat it as a relative path instead).

  • Save compose.yaml.

YAML indentation errors?

The volumes: key inside the db service must be indented at the same level as expose: and healthcheck:. The top-level volumes: must be at column 0 (no indentation), aligned with services:.

help me fix a YAML indentation error in my compose.yaml

✔️ Awesome, I've got everything!

Great. Make sure you have saved compose.yaml before moving on.

ⓧ I'd like to double check the full code

Your compose.yaml should have these sections in order:

  • The app service with build, ports, environment, and depends_on.
  • The db service with image, restart, environment, expose, volumes (- db-data:/var/lib/postgresql/data), and healthcheck.
  • A top-level volumes: section declaring db-data:.

Scroll up and confirm each section is present and correctly indented.

Verify persistence across teardowns

The volume is declared. Time to prove it works by repeating the exact sequence that lost your data before.

  • Rebuild and start the stack with the new volume by running:
docker compose up --build -d
  • Wait for both services to be healthy, then open http://127.0.0.1:8000/docs.
  • Use POST /api/v1/documents/ to create a document. Use any title and content you like.
  • Use GET /api/v1/documents/ to confirm it appears.

Now predict: will this document survive a full teardown this time?

  • Tear down the stack by running:
docker compose down
  • Bring it back up by running:
docker compose up --build -d
  • Once the services are healthy, open http://127.0.0.1:8000/docs and use GET /api/v1/documents/.

Your document is still there. The named volume kept PostgreSQL's data safe on your host while the container was destroyed and recreated.

What makes this different from before?

Without a volume, docker compose down removes the container and its writable layer where PostgreSQL stored data. With the named volume db-data, that data lives on your host machine. The new container mounts the same volume and picks up right where the old one left off.

Note that docker compose down -v would remove named volumes too. Without the -v flag, volumes are preserved.

Documents still disappearing?

Make sure you added both the service-level volumes: mount under db AND the top-level volumes: declaration at the bottom. Missing either one means the volume is not created correctly.

Also confirm you are running docker compose down (without -v) when testing persistence. The -v flag explicitly removes volumes.

help me debug why my Docker volume is not persisting PostgreSQL data

Your entire stack is now production-ready for local development. Data persists, services start in the right order, and anyone with Docker can run your project with a single command. Next up, you will tackle the Secret Mission and optimize your image for production.

Secret mission

Optimize Your Dockerfile with a Multi-Stage Build

Your Docker image works, but it ships the entire uv toolchain to production. That's dead weight. In this secret mission, you'll restructure your Dockerfile into a multi-stage build that cuts image size significantly, making deploys faster and your attack surface smaller.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Docker images stored locally (free, takes disk space).
  • Docker volumes storing PostgreSQL data (free, takes disk space).
  • Running containers consuming memory when up.

Keep everything running

No action needed. Choose this if you are still actively building on your Enterprise Knowledge Assistant.

  • Containers stop automatically when you close Docker Desktop or run docker compose down in your project folder.
  • Your named volume db-data persists on disk until you explicitly remove it.
  • Your Docker image stays cached locally so future builds are fast.

Pause - I'll come back to this later

Shut down running containers to free up memory. Your volume keeps your data safe so you can pick up exactly where you left off.

  • Stop and remove containers by running:
  • Your volume (db-data) persists, so all documents you created are safe.
  • When you are ready to resume, run docker compose up --build from your project folder.
docker compose down

Delete - I don't want to use this again

Remove all project containers, volumes, and images. This deletes your stored documents permanently.

  • Remove containers and the named volume (all stored data is gone) by running:
docker compose down -v
  • Remove the project image by running:
docker image rm enterprise-knowledge-assistant
  • Clean up dangling images and build cache by running:
docker system prune

What does docker system prune do?

This command removes all stopped containers, unused networks, dangling images, and build cache. Docker will ask you to confirm before proceeding.

Nice Work!

Nice Work!

You just containerized an entire API and database stack with Docker. Hand someone your repo and they run one command to get everything running.

You've learned how to:

  • Built a production-ready Dockerfile using uv that packages your FastAPI app into a portable container image.
  • Orchestrated a multi-service stack with Docker Compose where your API waits for PostgreSQL health before starting.
  • Migrated from in-memory storage to a real database using SQLModel, with documents persisting across container restarts.
  • Used named volumes to survive full container teardowns, ensuring data outlives any individual container.
  • Secret Mission: Optimized image size with a multi-stage Docker build, removing build tools from the production image.

Ready to quiz yourself?