How to deploy a machine learning model to production

To deploy a machine learning model to production, wrap it in a small API with strict request and response schemas, load the model once at startup, and expose health and version endpoints. Then package the service with the exact model artefact it was tested with in a container image, and let a CI/CD pipeline test, build, tag and release it, versioning the model, prompts, rules and configuration alongside the code so that any release can be rolled back.

By Adapted from Chapter 13, Packaging & Deploying AI Systems, of From Model to Product ·

Choose the serving pattern first

Most models that reach users are served as a real-time API: a request arrives, the service runs the model, and the answer goes back while the client waits. That is the right default when requests are short, inference is fast and the result is needed now. It is not the only pattern, and picking the wrong one is harder to undo than any line of code.

Where the model runs is an architecture decision, not a deployment detail; AI system architecture: a reference design shows where the serving layer sits in the whole product. This guide takes the common case, a real-time API, and its packaging applies to every pattern.

PatternUse it whenWatch out for
Real-time APIRequests are short, inference is fast, traffic is moderate and the answer is needed immediatelyA slow or expensive model makes every caller wait
BatchLatency does not matter, volumes are large, the model is expensive and results are used laterPredictions go stale; they need storing and serving
Queue and workersModels are slow, requests are heavy, GPUs are scarce or traffic arrives in burstsClients need a job ID and a way to collect the result
EdgeLatency, offline use or server cost dominateDevice performance varies, models must be small, updates and debugging are harder
HybridA small, fast model handles common cases and a larger one takes the restEvery component must be reachable, with timeouts and fallbacks

Six questions every inference API must answer

A good inference API is boring in the best sense: clients know exactly what to send, what comes back and how failures behave, and they never have to reverse-engineer a response. Answer six questions before writing the endpoint, because the contract becomes the stable point the rest of the system is built around. The service in the next section answers them like this.

QuestionAnswer in this service
Which endpoint does the client call?POST /v1/predict, with the API version in the path
What request schema does it accept?A Pydantic model with typed, bounded fields; unknown fields are rejected
What response schema does it return?A probability, a decision and the model version, every time
What happens when validation fails?FastAPI returns 422 with the failing field before the model runs
What happens when the model is unavailable?The service will not start without it; at request time it returns 503, and an inference error returns a generic 500 with the detail kept in the logs
How is versioning handled?The API version is in the path; the model version is in every response and on GET /version

A minimal FastAPI inference service

The code below is a complete service for a scikit-learn classifier saved with joblib, trained on three features in a fixed order. Swap the features for your own; the patterns stay the same.

Four details carry most of the weight. The model loads once, in the lifespan handler, rather than on every request; lifespan replaces FastAPI's deprecated startup events, and if loading fails the service refuses to start, so a broken image never takes traffic. The endpoint is a plain def, not async def, so FastAPI runs it in a thread pool and CPU-bound inference cannot block the event loop. The request schema forbids extra fields, so a misspelt field earns a 422 instead of a silent default. And the 500 path logs the exception but returns a generic message, because raw exception text leaks internals to callers.

The model path, model version and decision threshold come from environment variables. That is the separation of code, model and configuration in miniature: the threshold is a business setting, and changing it should not need a new build.

Load only model files you produced yourself. Pickle and joblib files can execute code when they are loaded; for artefacts that cross a trust boundary, export to a format such as ONNX instead.

import logging
import os
from contextlib import asynccontextmanager

import joblib
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field

# Configuration lives outside the code, so a release can change it without a rebuild.
MODEL_PATH = os.getenv("MODEL_PATH", "models/churn.joblib")
MODEL_VERSION = os.getenv("MODEL_VERSION", "dev")
THRESHOLD = float(os.getenv("DECISION_THRESHOLD", "0.5"))

log = logging.getLogger("inference")
ml = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load once, before the first request. If this fails, the service never starts.
    ml["model"] = joblib.load(MODEL_PATH)
    yield
    ml.clear()


app = FastAPI(title="Churn risk API", version="1.0.0", lifespan=lifespan)


class CustomerFeatures(BaseModel):
    model_config = ConfigDict(extra="forbid")  # unknown fields are rejected, not ignored

    tenure_months: int = Field(ge=0, le=600)
    monthly_spend: float = Field(ge=0, le=100_000)
    support_tickets_90d: int = Field(ge=0, le=500)


class Prediction(BaseModel):
    churn_probability: float = Field(ge=0, le=1)
    will_churn: bool
    model_version: str


@app.get("/health")
def health() -> dict:
    return {"status": "ok", "model_loaded": "model" in ml}


@app.get("/version")
def version() -> dict:
    return {"api_version": app.version, "model_version": MODEL_VERSION}


@app.post("/v1/predict")
def predict(features: CustomerFeatures) -> Prediction:
    # A plain def runs in FastAPI's thread pool, so CPU-bound inference
    # does not block the event loop.
    model = ml.get("model")
    if model is None:
        raise HTTPException(status_code=503, detail="Model not loaded")

    # Same feature order as training.
    row = [[features.tenure_months, features.monthly_spend, features.support_tickets_90d]]
    try:
        probability = float(model.predict_proba(row)[0][1])
    except Exception:
        log.exception("Inference failed")  # full detail in the logs
        raise HTTPException(status_code=500, detail="Inference failed")  # none for the client

    return Prediction(
        churn_probability=probability,
        will_churn=probability >= THRESHOLD,
        model_version=MODEL_VERSION,
    )
main.py: a complete inference service with Pydantic 2 schemas, lifespan loading, and health and version endpoints.

Package it with Docker

A container ships the service together with its Python version, libraries and model, so it behaves the same on a laptop, in CI and in production. AI services are unusually sensitive to their environment: a model saved under one version of a library is not guaranteed to load, or to behave the same, under another. For example, scikit-learn warns when a model is loaded under a different version from the one that saved it.

The Dockerfile starts from a slim base image, installs dependencies before copying the code so that layer is cached between builds, and then adds the code and the model. Two additions matter in production: the process runs as an unprivileged user, and the image carries a health check. The fastapi run command comes with fastapi[standard] and listens on 0.0.0.0:8000 by default. Kubernetes ignores Docker's own health check and uses liveness and readiness probes instead, so point those at /health as well.

Pin exact versions in requirements.txt (fastapi[standard], scikit-learn and joblib here), using the versions your tests passed with, captured with pip freeze from that environment. Keep the serving image smaller than the training environment: no notebooks, training frameworks or experiment tracking. Add a .dockerignore for .git, virtual environments and data, and keep secrets such as API keys out of the image entirely; pass them in at run time.

Tag each image immutably, with a semantic version or the commit hash, rather than relying on latest, because a tag you can name is a tag you can roll back to. Run one server process per container and let the platform add replicas.

# Build: docker build -t churn-api:1.4.0 .
# Run:   docker run -p 8000:8000 churn-api:1.4.0
FROM python:3.13-slim

# No .pyc files, logs straight to stdout, no pip cache left in the image
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app

# Dependencies first: this layer is reused until requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt

# Then the code, and the model artefact it was tested with
COPY main.py .
COPY models/ models/

# The version travels with the image; set both from your pipeline
ENV MODEL_PATH=models/churn.joblib \
    MODEL_VERSION=1.4.0

# Never run as root
RUN useradd appuser
USER appuser

EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

# fastapi run serves on 0.0.0.0:8000 in production mode
CMD ["fastapi", "run", "main.py"]
Dockerfile for the service above. The first two lines show how to build and run it.

Ship the model file: bundle, download or mount

Model files are the part of an AI service that general deployment advice forgets. There are three ways to get one into a running container, and the right choice depends on the model's size and how often it changes.

ApproachStrengthsCostsFits
Bundle it in the imageThe image holds exactly the model it was tested with; rollback is the previous imageBigger images, and a rebuild for every model changeSmall models and small teams; the default above
Download it at startupModels change without rebuilding the application imageStartup depends on the registry or object store; the version must be pinned in configurationModels that change more often than the code
Mount it from storageVery large artefacts stay out of the image and can be sharedThe image no longer proves which model runs; versioning moves to storageLarge models and platform teams

CI/CD that versions more than code

In ordinary software the code defines the behaviour. In an AI system the behaviour also depends on the model weights, the label mapping, the tokenizer, the prompt templates, the rules and the configuration, and changing any one of them changes the system even when no code changed. That holds whether the component is a classifier, a rule engine or a language model feature, so the pipeline must version and release all of them together. A typical pipeline runs four stages, and a failure at any stage stops the release:

  1. Test: unit tests, plus checks that the model loads, inference returns valid output, configuration can be read and dependencies connect.
  2. Build: the same docker build command every time, from a clean checkout.
  3. Push: an immutably tagged image to a registry, so servers pull exactly the build that passed.
  4. Deploy: a release strategy that limits the damage a bad version can do, with the previous version kept ready.

Know which model is running, and prove it works

A model registry records each model's version, training parameters, metrics and deployment status. MLflow or a cloud registry will do, and for a small system versioned object storage is enough. What matters is that you can always say which model is serving traffic, rebuild it from its inputs, and trace how it got there.

The smoke test below is the cheapest insurance in the pipeline. It stops an image that cannot load its model, answer a valid request or reject a bad one from ever being built. Open TestClient in a with statement, as here; without it the lifespan never runs and the model never loads.

from fastapi.testclient import TestClient

from main import app

GOOD = {"tenure_months": 18, "monthly_spend": 42.5, "support_tickets_90d": 3}


def test_model_loads_and_predicts():
    with TestClient(app) as client:  # "with" runs the lifespan, so the model loads
        assert client.get("/health").json()["model_loaded"] is True
        response = client.post("/v1/predict", json=GOOD)
        assert response.status_code == 200
        body = response.json()
        assert 0 <= body["churn_probability"] <= 1
        assert body["model_version"] == client.get("/version").json()["model_version"]


def test_bad_input_is_rejected_before_inference():
    with TestClient(app) as client:
        assert client.post("/v1/predict", json={**GOOD, "tenure_months": -1}).status_code == 422
        assert client.post("/v1/predict", json={**GOOD, "surprise": 1}).status_code == 422
test_main.py: run with pytest in CI before docker build. fastapi[standard] includes the HTTP client that TestClient needs.

Release gradually and keep a way back

Replacing the running version in one step is the riskiest release there is. Blue-green, canary and shadow releases let a new model meet real traffic gradually; how to roll out a new ML model safely compares them. Whichever you use, rollback is only possible when four things hold:

  • Old images stay in the registry under their own tags.
  • Old model versions are stored with their label mappings and preprocessing.
  • Database changes remain compatible with the previous version.
  • Configuration, prompts and rules are versioned rather than edited in place.

The important idea is not the tool, but the discipline. Every deployed model should have a known version. Every version should be reproducible. Every change should be traceable.

From Chapter 13 of From Model to Product

Questions

What should I monitor once the model is live?

More than the server, because a model service can be up, fast and wrong at the same time. From day one, track latency percentiles, error rates and validation failures, then the model's own signals: the share of low-confidence or unknown predictions, the distribution of predicted classes, how often fallbacks run, and cost per request. Shifts in those numbers are usually the first sign of data drift or concept drift.

Is Flask good enough to serve a model in production?

Yes. Flask is still a valid choice, especially for small internal services or simple endpoints a team already knows how to run. The difference is how much structure you get by default: FastAPI brings request validation through Pydantic, type hints, automatic OpenAPI documentation and async support, while Flask leaves more of that to you. For model APIs, where the contract matters as much as the model, that usually tips the balance towards FastAPI.

Do I need Kubernetes to deploy a machine learning model?

No. One container on a virtual machine or a managed container service is enough for many models, and Docker Compose covers local development with a database alongside. Kubernetes earns its cost when several services need independent scaling, rolling deployments and workload isolation across many machines, or when your organisation already runs it. The image you build is the same either way, so you can move later without rewriting the service.

Should FastAPI inference endpoints be async def or def?

Use a plain def when the endpoint runs CPU-bound inference in the process, as most scikit-learn, gradient boosting and ONNX models do. FastAPI runs def endpoints in a thread pool, whereas CPU work inside async def blocks the event loop and stalls every other request on that worker. Use async def when the endpoint mostly waits on I/O, such as a call to a remote model or a language model API through an async client.

Do I need a GPU to serve a model in production?

Often not. A common early mistake is to assume that because training used a GPU, serving must too, yet tree-based, linear and many small neural models serve comfortably on CPU, especially when heavy work runs offline. GPUs earn their cost for deep vision models, large transformers and generative workloads at volume, and smaller models, quantisation, batching and offline scoring all reduce how much GPU you need.

How should I test a model service before real users see it?

In three layers. In CI, smoke tests confirm that the model loads, valid input gets a sensible answer and bad input is rejected. Before launch, load test the whole user workflow with a tool such as Locust, k6 or Apache JMeter, watching latency, throughput, queue length and error rate as simulated users increase. Then run the new version in shadow mode on real traffic before any user depends on it.

About Dr. Dulan Dias