Articles / MLOps

[ MLOps ]FastAPIDockerscikit-learnPython

Serve a scikit-learn Model with FastAPI and Docker

Train an Iris classifier and serve it with FastAPI. Protect both prediction routes, validate finite inputs, bound batches and test a Docker container.

March 19, 2026·7 min read
Serve a scikit-learn Model with FastAPI and Docker

A prediction API needs a clear request contract, a trusted model artifact and a way to reject unauthorized calls. This walkthrough trains an Iris classifier, serves single predictions and batches, and packages the same example into Docker. It is a local teaching example, with no measured throughput or production-readiness claim.

Files and tested versions

Use Python 3.13 and the following direct dependencies in requirements.txt. The commands below use PowerShell. Keep training and serving dependencies aligned; lock transitive dependencies and the base-image digest for a release.

text
iris-api/
  requirements.txt
  train_model.py
  test_api.py
  app/
    main.py
  Dockerfile
text
fastapi==0.138.0
pydantic==2.13.4
uvicorn==0.49.0
scikit-learn==1.9.0
numpy==2.4.6
joblib==1.5.3
httpx==0.28.1

Train and save a trusted artifact

The scikit-learn introduction explains model evaluation. Here, a stratified holdout keeps each Iris class represented. The printed accuracy describes this small split, not deployment quality or future data.

python
# train_model.py
from pathlib import Path
import joblib
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split

iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
    iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
model = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=1)
model.fit(X_train, y_train)
print(f"Holdout accuracy: {accuracy_score(y_test, model.predict(X_test)):.4f}")

model_dir = Path(__file__).resolve().parent / "model"
model_dir.mkdir(exist_ok=True)
joblib.dump(model, model_dir / "classifier.joblib")
joblib.dump(iris.target_names.tolist(), model_dir / "class_names.joblib")

Load only artifacts produced by your trusted training pipeline: joblib loading can execute code. Matching scikit-learn and dependency versions matters; copying an old artifact into a newly upgraded serving environment is unsupported. The Docker example below trains inside its own environment.

Protect both prediction routes

Create app/main.py. Inputs are positive, finite JSON numbers; strings, booleans and extra fields are rejected. A batch contains 1–128 records. Both endpoints use the same API-key dependency and prediction helper, preserving input order.

RandomForest converts inputs to float32 internally. A finite JSON number such as 1e100 exceeds that format and can otherwise turn inference into a server error. The schema applies its numeric upper limit to every feature; a real service also needs domain-specific ranges, not just numerical validity.

Configure API_KEY before importing the app. Missing or blank configuration prevents startup; missing and incorrect request keys return 401. Do not add a second handler for the same route to introduce authentication: attach the dependency to the original single and batch routes.

python
# app/main.py
from contextlib import asynccontextmanager
import os
from pathlib import Path
import secrets
from typing import Annotated
import joblib
import numpy as np
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, ConfigDict, Field

EXPECTED_API_KEY = os.environ.get("API_KEY")
if not EXPECTED_API_KEY or not EXPECTED_API_KEY.strip():
    raise RuntimeError("API_KEY must be configured before starting the API")

MODEL_DIR = Path(__file__).resolve().parents[1] / "model"
ml_models = {}
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

def require_api_key(key: str | None = Security(api_key_header)):
    if key is None or not secrets.compare_digest(
        key.encode("utf-8"), EXPECTED_API_KEY.encode("utf-8")
    ):
        raise HTTPException(
            status_code=401, detail="Invalid API key",
            headers={"WWW-Authenticate": "APIKey"},
        )

MAX_FLOAT32 = float(np.finfo(np.float32).max)

class IrisFeatures(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)
    sepal_length: float = Field(gt=0, le=MAX_FLOAT32, description="Sepal length in cm")
    sepal_width: float = Field(gt=0, le=MAX_FLOAT32, description="Sepal width in cm")
    petal_length: float = Field(gt=0, le=MAX_FLOAT32, description="Petal length in cm")
    petal_width: float = Field(gt=0, le=MAX_FLOAT32, description="Petal width in cm")

class PredictionResponse(BaseModel):
    prediction: int
    class_name: str
    probabilities: list[float]

@asynccontextmanager
async def lifespan(app: FastAPI):
    ml_models["classifier"] = joblib.load(MODEL_DIR / "classifier.joblib")
    ml_models["class_names"] = joblib.load(MODEL_DIR / "class_names.joblib")
    try:
        yield
    finally:
        ml_models.clear()

app = FastAPI(title="Iris Classifier API", version="1.0.0", lifespan=lifespan)

@app.exception_handler(RequestValidationError)
async def invalid_request(request, error: RequestValidationError):
    # Raw invalid values can contain NaN/infinity, which JSON cannot serialize.
    details = [{"loc": item["loc"], "msg": item["msg"], "type": item["type"]}
               for item in error.errors()]
    return JSONResponse(status_code=422, content={"detail": details})

@app.get("/health")
def health_check():
    if "classifier" not in ml_models or "class_names" not in ml_models:
        raise HTTPException(status_code=503, detail="Model unavailable")
    return {"status": "healthy", "model_loaded": True}

def predict_many(features_list: list[IrisFeatures]):
    if "classifier" not in ml_models or "class_names" not in ml_models:
        raise HTTPException(status_code=503, detail="Model unavailable")
    matrix = np.array([[f.sepal_length, f.sepal_width, f.petal_length, f.petal_width]
                       for f in features_list])
    model = ml_models["classifier"]
    predictions = model.predict(matrix).tolist()
    probabilities = model.predict_proba(matrix).tolist()
    return [
        PredictionResponse(
            prediction=label, class_name=ml_models["class_names"][label],
            probabilities=scores,
        )
        for label, scores in zip(predictions, probabilities)
    ]

@app.post("/predict", response_model=PredictionResponse,
          dependencies=[Depends(require_api_key)])
def predict(features: IrisFeatures):
    return predict_many([features])[0]

@app.post("/predict/batch", response_model=list[PredictionResponse],
          dependencies=[Depends(require_api_key)])
def predict_batch(features_list: Annotated[list[IrisFeatures], Field(min_length=1, max_length=128)]):
    return predict_many(features_list)

/health is public and returns 503 if the model is unavailable. Missing artifacts fail startup. The validation handler omits raw invalid input so NaN or infinity cannot turn a validation response into a JSON serialization error. Probabilities follow model.classes_—[setosa, versicolor, virginica] for this model—and are not automatically calibrated confidence.

Exercise the API without a running server

Create test_api.py. TestClient enters the lifespan, loads the actual saved model and sends requests to the application in process. These checks cover usable outputs, authentication on both endpoints and input boundaries.

python
# test_api.py
import os
from fastapi.testclient import TestClient
from app.main import app

sample = {"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2}
headers = {"X-API-Key": os.environ["API_KEY"]}
with TestClient(app) as client:
    assert client.get("/health").status_code == 200
    single = client.post("/predict", json=sample, headers=headers)
    single.raise_for_status()
    assert single.json()["class_name"] == "setosa"
    batch = client.post("/predict/batch", json=[sample, sample], headers=headers)
    batch.raise_for_status()
    assert batch.json() == [single.json(), single.json()]
    for route, payload in [("/predict", sample), ("/predict/batch", [sample])]:
        assert client.post(route, json=payload).status_code == 401
        assert client.post(route, json=payload, headers={"X-API-Key": "wrong"}).status_code == 401
    assert client.post("/predict/batch", json=[], headers=headers).status_code == 422
    assert client.post("/predict/batch", json=[sample] * 129, headers=headers).status_code == 422
    assert client.post("/predict", json={**sample, "sepal_length": 0}, headers=headers).status_code == 422
    for route, payload in [("/predict", {**sample, "sepal_length": 1e100}),
                           ("/predict/batch", [{**sample, "sepal_length": 1e100}])]:
        assert client.post(route, json=payload, headers=headers).status_code == 422
print("API contract checks passed")

From the project root, install, generate a local demo key, train and run the checks. The generated key is assigned to the environment rather than written into source or printed by the shell.

powershell
python -m venv .venv
& .\.venv\Scripts\python.exe -m pip install -r requirements.txt
$env:API_KEY = & .\.venv\Scripts\python.exe -c "import secrets; print(secrets.token_urlsafe(32))"
& .\.venv\Scripts\python.exe train_model.py
& .\.venv\Scripts\python.exe test_api.py

To explore the API over HTTP, run the following command and open the local API docs. Use Swagger's Authorize control with the configured key for prediction calls. Stop the server before starting the container on the same port.

powershell
& .\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000

Build and test the container

The image installs the same direct versions, trains the toy model in that environment and runs as an unprivileged user. The key is supplied at runtime. Save this as Dockerfile:

dockerfile
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
ENV OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir --only-binary=:all: -r requirements.txt
COPY train_model.py test_api.py ./
COPY app/ ./app/
RUN python train_model.py && useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

The first run executes the same contract checks inside Docker, without network access or a persistent server. The second serves HTTP on the host's loopback interface; it runs in the foreground and is removed when stopped.

powershell
docker build -t iris-classifier:demo .
docker run --rm --network none --env API_KEY iris-classifier:demo python test_api.py
docker run --rm --env API_KEY -p 127.0.0.1:8000:8000 iris-classifier:demo

Before exposing a real service

A shared API key does not provide user identity, roles, tenant isolation or automatic rotation. A real deployment needs HTTPS, managed secrets, request-size and rate limits, artifact provenance, monitoring and measured resource capacity. Each worker loads its own model; adding processes consumes memory. The synchronous handlers allow FastAPI to run inference off the event loop, but do not make CPU inference faster.

The historical fast-ml-deploy repository uses /v1/iris/predict and a different payload. Its README and this complete example describe separate interfaces. Continue with pipeline orchestration and labeled quality/data drift monitoring.

Sources

Are you building an AI or data platform?

If you need to adapt this approach to your system, tell me about your data and the technical decision you need to resolve.

View all projects