Build a Production ML Prediction API

Train an XGBoost model and serve predictions via a Dockerized Django REST API.

Introduction

30 Second Summary

Most machine learning models never leave the notebook they were trained in. The gap between "my model gets a good score" and "my model is answering real requests in production" is where ML engineering lives.

In this project, you will train a house price prediction model, wrap it in a REST API, containerize the whole system with Docker, and add automated tests that run on every push to GitHub.

What You'll Build

A production-grade house price prediction API that uses XGBoost inside a scikit-learn Pipeline, served through Django REST Framework, and deployed in Docker with GitHub Actions CI.

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

  • A trained ML pipeline artifact that preprocesses data and predicts house prices, saved as a single serializable file.
  • A Django REST Framework prediction API that loads the model and serves predictions via HTTP with input validation and a health check endpoint.
  • A Dockerfile and CI pipeline that containerizes the system and runs automated tests on every push to GitHub.
  • Secret Mission: Add model versioning and prediction logging to track every prediction your API makes.

Are there any prerequisites?

You should have basic Python knowledge and be comfortable using the command line. No prior experience with Django, machine learning, or Docker is required.

Before We Start

Before writing any ML code, you need a working Python environment with the right packages installed.

In this step, you'll install Python 3.12, create an isolated virtual environment, install all the ML and web framework dependencies, and scaffold the project folder structure.

In this step, get ready to:
  • Install Python 3.12 and create a virtual environment.
  • Install all project dependencies from a requirements file.
  • Create the project directory structure and initialize a Git repository.
Install Python 3.12, create a virtual environment, and verify the installation

This project requires Python 3.12 because scikit-learn 1.8.0 needs Python 3.11 or higher.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.12 or higher

Python 3.12 is ready. You can move on to creating your project folder below.

ⓧ I see a version lower than 3.12

Your system has an older Python installed. You need to install Python 3.12 alongside it.

macOS often ships with Python 3.10 or 3.11 as the system default. Installing 3.12 separately does not overwrite the system version.

  • Visit python.org/downloads and download the Python 3.12 installer for your operating system.
  • Run the installer. On Windows, check Add Python to PATH when prompted.
  • After installation, close and reopen your terminal.
  • Verify the new version by running:
python3.12 --version

You should see Python 3.12.x in the output. Use python3.12 instead of python3 for all remaining commands in this step.

ⓧ Command not found

Python is not installed on your system yet.

  • Visit python.org/downloads and download the Python 3.12 installer for your operating system.
  • Run the installer. On Windows, check Add Python to PATH when prompted.
  • After installation, close and reopen your terminal.
  • Verify by running:
python3 --version

You should see Python 3.12.x in the output.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir house-price-api
cd house-price-api
  • Create a virtual environment by running:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here won't conflict with other projects on your machine.

  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activate command from inside your house-price-api folder. If you used python3.12 to create the venv, ensure you are using the same command to activate it.

On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try again.

Help me activate my virtual environment.

Install project dependencies

This project uses several libraries. You'll list them all in a requirements file so they can be installed in one command.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the house-price-api folder, then click Open.
  • Create a new file called requirements.txt by clicking File then New File.
  • Paste the following content into requirements.txt:
Django==5.2.14
djangorestframework==3.17.1
scikit-learn==1.8.0
xgboost==3.2.0
pandas==2.2.3
joblib==1.4.2
gunicorn==23.0.0
pytest==8.3.4
pytest-django==4.9.0

What are these packages?

  • Django and Django REST Framework power the web API that serves predictions.
  • scikit-learn provides the ML pipeline (preprocessing and model chaining).
  • XGBoost is the gradient boosting model that predicts house prices.
  • pandas handles data loading. joblib serializes the trained model to disk.
  • gunicorn is the production web server. pytest runs the test suite.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Back in your terminal (with (venv) active), install all dependencies by running:
pip install -r requirements.txt

This will download and install all nine packages plus their sub-dependencies. It may take a minute or two.

  • Verify the installation succeeded by running:
python3 -c "import django; import rest_framework; import sklearn; import xgboost; print('All packages installed!')"

You should see All packages installed! printed in your terminal.

Seeing an ImportError?

Make sure (venv) appears in your terminal prompt. If it does not, re-activate the virtual environment and run the install command again.

If xgboost fails to install, check that your Python version is 3.12 (not 3.9 or 3.10). XGBoost 3.2.0 requires Python 3.10 or higher.

Help me fix my package installation error.

✔️ Awesome, I've got everything!

Great. Make sure requirements.txt is saved.

ⓧ I'd like to double check the full code

Django==5.2.14
djangorestframework==3.17.1
scikit-learn==1.8.0
xgboost==3.2.0
pandas==2.2.3
joblib==1.4.2
gunicorn==23.0.0
pytest==8.3.4
pytest-django==4.9.0
Create the project directory structure and initialize a Git repository

Django expects a specific folder layout. You'll create the directories now so everything is in place when you start writing code.

  • Create the project directories by running these commands in your terminal:
mkdir config predictions tests data ml_artifacts
mkdir -p .github/workflows

What are these folders for?

  • config/ holds Django project settings and URL configuration.
  • predictions/ is the Django app that handles prediction requests.
  • tests/ contains automated test files.
  • data/ stores the training dataset.
  • ml_artifacts/ is where the trained model file will live.
  • .github/workflows/ holds the CI pipeline configuration for GitHub Actions.
  • Initialize a Git repository by running:
git init
  • Confirm the directories and repo were created by running:
ls

You should see config, predictions, tests, data, ml_artifacts, requirements.txt, and venv in the output.

Missing a folder?

Check that you ran the mkdir commands from inside the house-price-api folder. Run pwd to confirm your current directory ends with house-price-api.

Help me fix my project directory structure.

Your development environment is ready. Next up, you'll train an ML model and save it as a reusable artifact.

Train and Serialize the ML Pipeline

Your development environment is ready. Now it's time to build the core ML artifact that your API will serve.

In this step, you'll train a house price prediction model using real estate data from Ames, Iowa. The key idea is packaging preprocessing and prediction into a single serializable artifact. This means your API can load one file and handle raw input without any separate data-cleaning step.

In this step, get ready to:
  • Download the Ames Housing dataset from Kaggle.
  • Build a scikit-learn Pipeline that preprocesses and predicts in one pass.
  • Train the model, evaluate its accuracy, and serialize it for production use.
Download the Ames Housing dataset

The model needs training data. You'll use the Ames Housing dataset from Kaggle's "House Prices - Advanced Regression Techniques" competition. This dataset contains 1,460 houses with features like square footage, overall quality, and neighborhood.

  • Confirm the file is in place by running:
ls data/

You should see train.csv listed in the output.

Don't see train.csv?

Make sure you extracted the zip file first. The download from Kaggle is a .zip archive, not a raw CSV.

Double-check you copied train.csv into the data/ folder inside your project directory, not the project root.

Help me get the Ames Housing dataset into my project

Build the scikit-learn Pipeline

A Pipeline chains preprocessing steps and a model into a single object. This means you serialize one artifact instead of juggling separate preprocessors and model files.

  • Create a new file called train_model.py in your project root (the same level as requirements.txt).
  • Add the imports, hashing utility, and the first half of the training function by pasting this code:
"""Train the house price prediction pipeline and save it as an artifact."""
import pandas as pd
import numpy as np
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
from xgboost import XGBRegressor
import joblib
import json
from datetime import datetime
import hashlib


def get_dataset_hash(df):
    """Generate a hash of the dataset for versioning."""
    return hashlib.md5(pd.util.hash_pandas_object(df).values.tobytes()).hexdigest()[:8]


def train_pipeline():
    """Train the ML pipeline and save artifacts."""
    # Load dataset
    df = pd.read_csv("data/train.csv")

    # Select features for the API
    numeric_features = ["GrLivArea", "OverallQual", "YearBuilt", "TotalBsmtSF", "GarageCars"]
    categorical_features = ["Neighborhood", "HouseStyle"]

    feature_columns = numeric_features + categorical_features
    X = df[feature_columns]
    y = np.log1p(df["SalePrice"])

    # Split data
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )

    # Build preprocessing pipeline
    numeric_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ])

    categorical_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="constant", fill_value="missing")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ])

    preprocessor = ColumnTransformer(transformers=[
        ("num", numeric_transformer, numeric_features),
        ("cat", categorical_transformer, categorical_features),
    ])

    # Full pipeline with model
    pipeline = Pipeline(steps=[
        ("preprocessor", preprocessor),
        ("model", XGBRegressor(
            n_estimators=100,
            learning_rate=0.1,
            max_depth=5,
            random_state=42,
        )),
    ])

What does this code do?

  • get_dataset_hash() creates a short fingerprint of the training data so you can track which dataset version produced a given model.
  • The code selects 5 numeric features (like square footage and build year) and 2 categorical features (neighborhood and house style) from the full dataset.
  • np.log1p() applies a log transformation to SalePrice. This stabilizes variance and makes the model's errors more evenly distributed across price ranges.
  • The numeric transformer fills missing values with the median and scales features to a standard range.
  • The categorical transformer fills missing values with the string "missing" and one-hot encodes each category.
  • ColumnTransformer applies the right transformer to the right columns automatically.
  • The final Pipeline chains preprocessing and the XGBoost regressor into one object that handles raw input end-to-end.

Why a Pipeline instead of separate steps?

When you serialize a Pipeline, the exact preprocessing logic travels with the model. Your API loads one file and calls pipeline.predict() on raw input. No risk of applying different scaling or encoding at serve time than at training time.

This eliminates an entire class of production bugs called training-serving skew.

Train, evaluate, and serialize the pipeline

The pipeline structure is ready. Now you'll add the code that trains it on the data, measures accuracy, and saves both the model artifact and its metadata.

  • In train_model.py, add the following code directly below the pipeline definition (still inside the train_pipeline() function):
    # Train
    pipeline.fit(X_train, y_train)

    # Evaluate
    y_pred = pipeline.predict(X_test)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    r2 = r2_score(y_test, y_pred)

    print(f"RMSE (log scale): {rmse:.4f}")
    print(f"R\u00b2 Score: {r2:.4f}")

    # Save pipeline
    joblib.dump(pipeline, "ml_artifacts/model.joblib")

    # Save metadata
    metadata = {
        "model_version": "v1.0",
        "training_date": datetime.now().isoformat(),
        "rmse": round(rmse, 4),
        "r2_score": round(r2, 4),
        "dataset_hash": get_dataset_hash(df),
        "features": {
            "numeric": numeric_features,
            "categorical": categorical_features,
        },
    }
    with open("ml_artifacts/metadata.json", "w") as f:
        json.dump(metadata, f, indent=2)

    print(f"\nModel saved to ml_artifacts/model.joblib")
    print(f"Metadata saved to ml_artifacts/metadata.json")

    return pipeline, metadata


if __name__ == "__main__":
    train_pipeline()

What does this code do?

  • pipeline.fit() trains the full pipeline (preprocessing + model) on the training split.
  • RMSE measures average prediction error on the log-transformed scale. R² measures how much variance the model explains (closer to 1.0 is better).
  • joblib.dump() serializes the entire pipeline (preprocessor + trained model) into a single .joblib file.
  • The metadata JSON records the model version, training date, performance metrics, dataset fingerprint, and feature lists. This is critical for tracking which model is running in production.
  • The if __name__ == "__main__" block lets you run this file directly as a script.
  • Save train_model.py.
  • Run the training script:
python train_model.py

You should see output showing the RMSE and R² scores, followed by confirmation that the model and metadata files were saved.

  • Verify the artifacts were created by running:
ls ml_artifacts/

You should see model.joblib and metadata.json listed.

Seeing a FileNotFoundError?

If the error mentions data/train.csv, double-check that you placed the Kaggle dataset in the data/ folder and that the file is named exactly train.csv.

If the error mentions ml_artifacts/, make sure that folder exists. Create it with mkdir ml_artifacts if needed.

Help me debug my training script error

✔️ Awesome, I've got everything!

Great. Double-check you've saved train_model.py and that both artifacts exist in ml_artifacts/.

ⓧ I'd like to double check the full code

Here's the complete train_model.py for comparison:

"""Train the house price prediction pipeline and save it as an artifact."""
import pandas as pd
import numpy as np
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
from xgboost import XGBRegressor
import joblib
import json
from datetime import datetime
import hashlib


def get_dataset_hash(df):
    """Generate a hash of the dataset for versioning."""
    return hashlib.md5(pd.util.hash_pandas_object(df).values.tobytes()).hexdigest()[:8]


def train_pipeline():
    """Train the ML pipeline and save artifacts."""
    # Load dataset
    df = pd.read_csv("data/train.csv")

    # Select features for the API
    numeric_features = ["GrLivArea", "OverallQual", "YearBuilt", "TotalBsmtSF", "GarageCars"]
    categorical_features = ["Neighborhood", "HouseStyle"]

    feature_columns = numeric_features + categorical_features
    X = df[feature_columns]
    y = np.log1p(df["SalePrice"])

    # Split data
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )

    # Build preprocessing pipeline
    numeric_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ])

    categorical_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="constant", fill_value="missing")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ])

    preprocessor = ColumnTransformer(transformers=[
        ("num", numeric_transformer, numeric_features),
        ("cat", categorical_transformer, categorical_features),
    ])

    # Full pipeline with model
    pipeline = Pipeline(steps=[
        ("preprocessor", preprocessor),
        ("model", XGBRegressor(
            n_estimators=100,
            learning_rate=0.1,
            max_depth=5,
            random_state=42,
        )),
    ])

    # Train
    pipeline.fit(X_train, y_train)

    # Evaluate
    y_pred = pipeline.predict(X_test)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    r2 = r2_score(y_test, y_pred)

    print(f"RMSE (log scale): {rmse:.4f}")
    print(f"R\u00b2 Score: {r2:.4f}")

    # Save pipeline
    joblib.dump(pipeline, "ml_artifacts/model.joblib")

    # Save metadata
    metadata = {
        "model_version": "v1.0",
        "training_date": datetime.now().isoformat(),
        "rmse": round(rmse, 4),
        "r2_score": round(r2, 4),
        "dataset_hash": get_dataset_hash(df),
        "features": {
            "numeric": numeric_features,
            "categorical": categorical_features,
        },
    }
    with open("ml_artifacts/metadata.json", "w") as f:
        json.dump(metadata, f, indent=2)

    print(f"\nModel saved to ml_artifacts/model.joblib")
    print(f"Metadata saved to ml_artifacts/metadata.json")

    return pipeline, metadata


if __name__ == "__main__":
    train_pipeline()

Your trained model is sitting in ml_artifacts/model.joblib, ready to predict house prices. Next up, you'll build the Django REST Framework API that loads this artifact and serves predictions over HTTP.

Build the Django REST Framework Prediction API

You have a trained ML pipeline that can predict house prices. But a .joblib file sitting on disk is useless to the outside world.

In this step, you will wrap your model in a Django REST Framework API that accepts house features over HTTP and returns a predicted price. This is the bridge between a trained model and a production service.

In this step, get ready to:
  • Create a Django project and predictions app.
  • Build a serializer that validates incoming house features.
  • Wire up prediction and health check endpoints.
Create the Django project and prediction app

Django organizes code into a project (global config) and one or more apps (feature modules). You will create a project called config and an app called predictions.

  • Scaffold the Django project in your current directory by running this command:
django-admin startproject config .
  • Create the predictions app by running:
python manage.py startapp predictions
  • Confirm both were created by running:
ls

You should see manage.py in the root, a config/ folder, and a predictions/ folder.

What did these commands do?

The startproject config . command created a config/ package (with settings.py, urls.py, wsgi.py) and a manage.py file in the current directory. The dot at the end prevents Django from creating an extra nested folder.

The startapp predictions command created a predictions/ package with boilerplate files for a Django app (views, models, tests, etc.).

Command not found?

If django-admin is not recognized, make sure your virtual environment is activated and Django is installed. Run pip install Django==5.2.14 if needed.

help me fix django-admin command not found

Now you need to replace the default Django settings with a stripped-down configuration tailored for a REST API.

  • Open config/settings.py in VS Code and replace its entire content with the following:
"""Django settings for the house price prediction API."""
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key-change-in-production")

DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True"

ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")

INSTALLED_APPS = [
    "django.contrib.contenttypes",
    "django.contrib.auth",
    "rest_framework",
    "predictions",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.common.CommonMiddleware",
]

ROOT_URLCONF = "config.urls"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
}

ML_MODEL_PATH = BASE_DIR / "ml_artifacts" / "model.joblib"
ML_METADATA_PATH = BASE_DIR / "ml_artifacts" / "metadata.json"

What does this settings file do?

  • INSTALLED_APPS registers rest_framework (DRF) and your predictions app. Unused Django apps like admin and sessions are removed to keep it lean.
  • REST_FRAMEWORK restricts output to JSON only. No browsable HTML API.
  • ML_MODEL_PATH and ML_METADATA_PATH point Django to your trained pipeline artifacts so views can load them.
  • Environment variables (DJANGO_SECRET_KEY, DJANGO_DEBUG) allow you to configure the app differently in production without editing code.
  • Save config/settings.py.

Next, replace the generated WSGI file with a minimal version.

  • Open config/wsgi.py and replace its entire content with:
"""WSGI config for the house price prediction API."""
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

What is WSGI?

WSGI (Web Server Gateway Interface) is the standard protocol that connects Python web frameworks to web servers. When you deploy later with Gunicorn, it uses this file as the entry point to serve your Django app.

  • Save config/wsgi.py.
  • Open predictions/apps.py and replace its content with:
"""App configuration for predictions."""
from django.apps import AppConfig


class PredictionsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "predictions"
  • Save predictions/apps.py.
  • Verify Django can find your settings by running:
python manage.py check

You should see System check identified no issues in the output.

Seeing an import error?

If you see an error about rest_framework not being found, confirm your virtual environment is active and run pip install djangorestframework==3.17.1.

help me fix the Django system check error

Create the serializer and prediction view

DRF serializers validate incoming request data before it reaches your model. This prevents invalid inputs (negative square footage, impossible year values) from causing cryptic errors deep inside your ML pipeline.

  • Create a new file called serializers.py inside the predictions/ folder and paste the following code:
"""Serializers for prediction input validation."""
from rest_framework import serializers


class HouseFeaturesSerializer(serializers.Serializer):
    """Validates house features for prediction."""

    GrLivArea = serializers.IntegerField(
        min_value=200, max_value=10000,
        help_text="Above grade living area in square feet"
    )
    OverallQual = serializers.IntegerField(
        min_value=1, max_value=10,
        help_text="Overall material and finish quality (1-10)"
    )
    YearBuilt = serializers.IntegerField(
        min_value=1800, max_value=2026,
        help_text="Original construction year"
    )
    TotalBsmtSF = serializers.IntegerField(
        min_value=0, max_value=10000,
        help_text="Total basement area in square feet"
    )
    GarageCars = serializers.IntegerField(
        min_value=0, max_value=5,
        help_text="Size of garage in car capacity"
    )
    Neighborhood = serializers.CharField(
        max_length=50,
        help_text="Physical location within Ames city"
    )
    HouseStyle = serializers.CharField(
        max_length=50,
        help_text="Style of dwelling (e.g., 1Story, 2Story)"
    )

What does this serializer do?

Each field maps to one of the features your ML pipeline expects. The min_value and max_value constraints reject obviously wrong inputs at the API boundary. If someone sends GrLivArea: -500, DRF returns a 400 error before your model ever sees the data.

The field names (GrLivArea, OverallQual, etc.) match the column names your pipeline was trained on. This is important because pandas DataFrames are column-name-sensitive.

  • Save predictions/serializers.py.

Now build the view that loads your trained model and returns predictions.

  • Open predictions/views.py and replace its entire content with:
"""Views for the prediction API."""
import json
import time
import numpy as np
import pandas as pd
import joblib
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from predictions.serializers import HouseFeaturesSerializer


class PredictView(APIView):
    """Handle house price prediction requests."""

    _model = None
    _metadata = None

    @classmethod
    def get_model(cls):
        """Load model lazily on first request."""
        if cls._model is None:
            cls._model = joblib.load(settings.ML_MODEL_PATH)
        return cls._model

    @classmethod
    def get_metadata(cls):
        """Load model metadata."""
        if cls._metadata is None:
            with open(settings.ML_METADATA_PATH) as f:
                cls._metadata = json.load(f)
        return cls._metadata

    def post(self, request):
        """Predict house price from input features."""
        start_time = time.time()

        serializer = HouseFeaturesSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(
                {"errors": serializer.errors},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Create DataFrame with validated data
        features = pd.DataFrame([serializer.validated_data])

        # Predict (model outputs log1p of price)
        model = self.get_model()
        log_prediction = model.predict(features)[0]
        predicted_price = float(np.expm1(log_prediction))

        metadata = self.get_metadata()
        latency_ms = round((time.time() - start_time) * 1000, 2)

        return Response({
            "predicted_price": round(predicted_price, 2),
            "model_version": metadata["model_version"],
            "latency_ms": latency_ms,
        })

What does PredictView do?

  • get_model() loads the .joblib pipeline lazily. It only reads from disk on the first request, then caches the model in memory for all subsequent calls.
  • The post() method validates input through the serializer, converts it to a DataFrame, runs the prediction, and reverses the log1p transform with np.expm1() to get the actual dollar price.
  • The response includes model_version and latency_ms so consumers know which model made the prediction and how long it took.

Why lazy loading?

Loading an ML model from disk takes time (especially large ones). By caching it as a class variable, the first request pays the load cost but every subsequent request gets instant predictions from memory.

  • Save predictions/views.py.

Seeing a red underline on imports?

If VS Code shows import errors for numpy, pandas, or joblib, make sure VS Code is using your virtual environment's Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and select Python: Select Interpreter, then choose the one inside your venv folder.

help me fix import errors in VS Code

Add a health check endpoint and wire up URL routing

Production APIs need a health check endpoint. Load balancers and monitoring tools hit this route to confirm the service is running without triggering expensive model inference.

  • Open predictions/views.py and add the following class at the bottom of the file, after PredictView:
class HealthCheckView(APIView):
    """Health check endpoint."""

    def get(self, request):
        """Return API health status."""
        model_loaded = PredictView._model is not None
        return Response({
            "status": "healthy",
            "model_loaded": model_loaded,
        })

What does the health check return?

It returns a simple JSON object with the API status and whether the model is currently loaded in memory. This lets monitoring tools distinguish between "the API is up" and "the API is up and the model is ready to serve predictions."

  • Save predictions/views.py.

Now connect your views to URL routes. You need two routing files: one for the app and one for the project.

  • Create a new file called urls.py inside the predictions/ folder and paste the following:
"""URL routing for predictions app."""
from django.urls import path
from predictions.views import PredictView, HealthCheckView

urlpatterns = [
    path("predict/", PredictView.as_view(), name="predict"),
    path("health/", HealthCheckView.as_view(), name="health"),
]

What does this routing do?

Each path() maps a URL pattern to a view class. The .as_view() call converts the class-based view into a callable that Django's URL dispatcher can use. The name parameter lets you reference these routes by name elsewhere in the project.

  • Save predictions/urls.py.
  • Open config/urls.py and replace its entire content with:
"""URL configuration for the house price prediction API."""
from django.urls import path, include

urlpatterns = [
    path("api/v1/", include("predictions.urls")),
]

Why the api/v1/ prefix?

Versioning your API (/api/v1/) means you can release breaking changes under /api/v2/ later without disrupting existing consumers. The include() delegates all routes under this prefix to the predictions/urls.py file you just created.

  • Save config/urls.py.
  • Verify all URL routing is correct by running:
python manage.py check

You should see System check identified no issues again, confirming Django can resolve all your URL patterns and view imports.

Seeing a ModuleNotFoundError?

If you see an error about predictions.urls or predictions.views not being found, double-check that your predictions/urls.py file is in the right location (inside the predictions/ folder, not the project root). Also confirm predictions/__init__.py exists (it was created by startapp).

help me fix my Django URL routing error

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete predictions/views.py with both views:

"""Views for the prediction API."""
import json
import time
import numpy as np
import pandas as pd
import joblib
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from predictions.serializers import HouseFeaturesSerializer


class PredictView(APIView):
    """Handle house price prediction requests."""

    _model = None
    _metadata = None

    @classmethod
    def get_model(cls):
        """Load model lazily on first request."""
        if cls._model is None:
            cls._model = joblib.load(settings.ML_MODEL_PATH)
        return cls._model

    @classmethod
    def get_metadata(cls):
        """Load model metadata."""
        if cls._metadata is None:
            with open(settings.ML_METADATA_PATH) as f:
                cls._metadata = json.load(f)
        return cls._metadata

    def post(self, request):
        """Predict house price from input features."""
        start_time = time.time()

        serializer = HouseFeaturesSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(
                {"errors": serializer.errors},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Create DataFrame with validated data
        features = pd.DataFrame([serializer.validated_data])

        # Predict (model outputs log1p of price)
        model = self.get_model()
        log_prediction = model.predict(features)[0]
        predicted_price = float(np.expm1(log_prediction))

        metadata = self.get_metadata()
        latency_ms = round((time.time() - start_time) * 1000, 2)

        return Response({
            "predicted_price": round(predicted_price, 2),
            "model_version": metadata["model_version"],
            "latency_ms": latency_ms,
        })


class HealthCheckView(APIView):
    """Health check endpoint."""

    def get(self, request):
        """Return API health status."""
        model_loaded = PredictView._model is not None
        return Response({
            "status": "healthy",
            "model_loaded": model_loaded,
        })

Here is predictions/urls.py:

"""URL routing for predictions app."""
from django.urls import path
from predictions.views import PredictView, HealthCheckView

urlpatterns = [
    path("predict/", PredictView.as_view(), name="predict"),
    path("health/", HealthCheckView.as_view(), name="health"),
]

Here is config/urls.py:

"""URL configuration for the house price prediction API."""
from django.urls import path, include

urlpatterns = [
    path("api/v1/", include("predictions.urls")),
]

Here is config/settings.py:

"""Django settings for the house price prediction API."""
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key-change-in-production")

DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True"

ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")

INSTALLED_APPS = [
    "django.contrib.contenttypes",
    "django.contrib.auth",
    "rest_framework",
    "predictions",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.common.CommonMiddleware",
]

ROOT_URLCONF = "config.urls"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
}

ML_MODEL_PATH = BASE_DIR / "ml_artifacts" / "model.joblib"
ML_METADATA_PATH = BASE_DIR / "ml_artifacts" / "metadata.json"

Your prediction API is fully wired up. Next, you will write tests and verify the endpoints respond correctly with real requests.

Write Tests and Verify the API

Your prediction API is functional, but shipping code without tests is like deploying a model without evaluating it first. You have no way to know if a future change breaks something.

In this step, you'll write automated tests that verify your API responds correctly to both valid and invalid input. Then you'll manually hit the endpoint with curl to see the full request-response cycle in action.

In this step, get ready to:
  • Write unit tests for the prediction endpoint using pytest and DRF's test client.
  • Run the test suite and verify the API manually with curl.
  • Test input edge cases and confirm proper error responses.
Write unit tests for the prediction endpoint

Automated tests give you confidence that your API behaves correctly after every change. You'll use pytest with pytest-django and DRF's APIClient to simulate HTTP requests without spinning up a real server.

  • Create the tests/ directory and the pytest configuration files by running these commands:
mkdir tests
touch tests/__init__.py
  • Create a file called pytest.ini in your project root (the house-price-api folder) and add this configuration:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests/*.py
python_classes = Test*
python_functions = test_*
  • Create a file called conftest.py inside the tests/ folder and add this setup code:
"""Pytest configuration for Django tests."""
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

import django
django.setup()

What do these files do?

  • pytest.ini tells pytest where your Django settings live and which files contain tests.
  • conftest.py bootstraps Django before any tests run, so your models and views are available.

Now you'll write the actual test functions that exercise your API endpoints.

  • Create a file called test_predictions.py inside the tests/ folder and add the following test code:
"""Tests for the prediction API."""
import pytest
from rest_framework.test import APIClient


@pytest.fixture
def client():
    return APIClient()


@pytest.fixture
def valid_house_data():
    return {
        "GrLivArea": 1500,
        "OverallQual": 7,
        "YearBuilt": 2005,
        "TotalBsmtSF": 1000,
        "GarageCars": 2,
        "Neighborhood": "CollgCr",
        "HouseStyle": "1Story",
    }


@pytest.mark.django_db
def test_predict_returns_price(client, valid_house_data):
    response = client.post("/api/v1/predict/", valid_house_data, format="json")
    assert response.status_code == 200
    assert "predicted_price" in response.data
    assert "model_version" in response.data
    assert response.data["predicted_price"] > 0


@pytest.mark.django_db
def test_predict_invalid_input(client):
    response = client.post("/api/v1/predict/", {"GrLivArea": -1}, format="json")
    assert response.status_code == 400
    assert "errors" in response.data


@pytest.mark.django_db
def test_health_check(client):
    response = client.get("/api/v1/health/")
    assert response.status_code == 200
    assert response.data["status"] == "healthy"

What does each test check?

  • test_predict_returns_price sends valid house features and checks that the response includes a positive predicted_price and a model_version.
  • test_predict_invalid_input sends an out-of-range value (GrLivArea: -1) and confirms the API returns HTTP 400 with an errors field.
  • test_health_check hits the health endpoint and verifies it returns "healthy".

The @pytest.mark.django_db decorator tells pytest to allow database access during the test, which Django requires even for APIs that don't directly query a database.

  • Save all three files: pytest.ini, tests/conftest.py, and tests/test_predictions.py.
  • Confirm the files exist by running:
ls tests/

You should see __init__.py, conftest.py, and test_predictions.py in the output.

Don't see all three files?

Make sure you created the files inside the tests/ folder, not in the project root. The __init__.py file can be empty but must exist for Python to recognize the folder as a package.

test files not showing up in pytest

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests/*.py
python_classes = Test*
python_functions = test_*
"""Pytest configuration for Django tests."""
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

import django
django.setup()
"""Tests for the prediction API."""
import pytest
from rest_framework.test import APIClient


@pytest.fixture
def client():
    return APIClient()


@pytest.fixture
def valid_house_data():
    return {
        "GrLivArea": 1500,
        "OverallQual": 7,
        "YearBuilt": 2005,
        "TotalBsmtSF": 1000,
        "GarageCars": 2,
        "Neighborhood": "CollgCr",
        "HouseStyle": "1Story",
    }


@pytest.mark.django_db
def test_predict_returns_price(client, valid_house_data):
    response = client.post("/api/v1/predict/", valid_house_data, format="json")
    assert response.status_code == 200
    assert "predicted_price" in response.data
    assert "model_version" in response.data
    assert response.data["predicted_price"] > 0


@pytest.mark.django_db
def test_predict_invalid_input(client):
    response = client.post("/api/v1/predict/", {"GrLivArea": -1}, format="json")
    assert response.status_code == 400
    assert "errors" in response.data


@pytest.mark.django_db
def test_health_check(client):
    response = client.get("/api/v1/health/")
    assert response.status_code == 200
    assert response.data["status"] == "healthy"
Run the test suite and verify the API with curl

Tests are only useful if you actually run them. Let's execute the suite and then manually hit the API to see a real prediction response.

  • Run the test suite by running this command:
pytest -v

You should see all 3 tests pass with green output showing PASSED next to each test name.

Tests failing?

  • If you see an import error mentioning Django, make sure your virtual environment is activated and pytest-django is installed.
  • If test_predict_returns_price fails, confirm that ml_artifacts/model.joblib exists from when you trained the model in Step 2.
  • If you see a DJANGO_SETTINGS_MODULE error, check that pytest.ini is in your project root (same level as manage.py).

pytest tests are failing with import or settings errors

Now let's verify the API manually to see the full JSON response.

  • Start the Django development server by running:
python manage.py runserver
  • Open a second terminal window.
  • Make sure you're in the same project directory and your virtual environment is activated.
  • Send a prediction request by running this curl command:
curl -X POST http://localhost:8000/api/v1/predict/ \
  -H "Content-Type: application/json" \
  -d '{"GrLivArea": 1500, "OverallQual": 7, "YearBuilt": 2005, "TotalBsmtSF": 1000, "GarageCars": 2, "Neighborhood": "CollgCr", "HouseStyle": "1Story"}'

You should see a JSON response containing predicted_price, model_version, and latency_ms. The predicted price will be a number representing the model's house price estimate in dollars.

What is latency_ms?

The latency_ms field shows how many milliseconds the prediction took from receiving the request to returning the response. This is useful for monitoring your API's performance in production.

Test edge cases and confirm error responses

A production API needs to handle bad input gracefully. Your serializer already validates inputs, so let's confirm it rejects incomplete and out-of-range requests.

  • Test a request with missing fields by running this curl command (with the dev server still running):
curl -X POST http://localhost:8000/api/v1/predict/ \
  -H "Content-Type: application/json" \
  -d '{"GrLivArea": 1500}'

You should see an HTTP 400 response with an errors object listing each missing required field.

  • Test a request with an out-of-range value by running:
curl -X POST http://localhost:8000/api/v1/predict/ \
  -H "Content-Type: application/json" \
  -d '{"GrLivArea": -1, "OverallQual": 7, "YearBuilt": 2005, "TotalBsmtSF": 1000, "GarageCars": 2, "Neighborhood": "CollgCr", "HouseStyle": "1Story"}'

You should see an HTTP 400 response with an error for GrLivArea because -1 is below the minimum value of 200 defined in your serializer.

Why does input validation matter for ML APIs?

ML models can produce nonsensical outputs when given garbage inputs. Your serializer acts as a gatekeeper that rejects requests before they ever reach the model.

This means the model only ever sees well-formed data, and callers get clear error messages telling them exactly what to fix.

  • Stop the development server by pressing Ctrl+C in the terminal where it's running.

Getting a 200 instead of 400?

  • Check that your HouseFeaturesSerializer in predictions/serializers.py has min_value=200 on the GrLivArea field.
  • Make sure you saved serializers.py after making changes in Step 3.

my API returns 200 for invalid data instead of 400

Your API is now tested and validated. Next up, you'll package everything into a Docker container so it can run anywhere.

Containerize with Docker

Your API is tested and serving predictions locally. The next challenge is making sure it runs the same way on any machine, not just yours.

Docker packages your application code, Python runtime, and all dependencies into an isolated container. In this step, you'll write a Dockerfile, define a service with Docker Compose, and verify your containerized API serves predictions.

In this step, get ready to:
  • Write a Dockerfile that packages your API with Gunicorn.
  • Create a docker-compose.yml to run the API as a service.
  • Build the Docker image and verify the container serves predictions.
Write the Dockerfile

Before writing any Docker files, you need Docker installed on your machine. Docker Desktop includes the Docker CLI, Docker Compose, and everything else you need.

  • Check that Docker is installed by running this command:
docker --version

✔️ I see a version number

Docker is ready. Continue to the next section below.

ⓧ Command not found

You need to install Docker Desktop. It includes Docker Engine, Docker CLI, and Docker Compose in one package.

  • Download and install Docker Desktop from the official page: https://docs.docker.com/get-started/get-docker/
  • Follow the installer for your operating system (macOS, Windows, or Linux).
  • Once installed, open Docker Desktop and wait for the engine to start (you'll see a green indicator in the bottom-left corner).
  • Run docker --version again to confirm it works.

A Dockerfile is a recipe that tells Docker how to build an image of your application. Each instruction creates a layer in the final image.

  • In VS Code's Explorer sidebar, create a new file called Dockerfile (no file extension) in the same folder as your requirements.txt.
  • Paste the following code into Dockerfile:
FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python 3.12 base image.
  • WORKDIR /app sets the working directory inside the container.
  • The two ENV lines prevent Python from writing .pyc files and ensure logs appear in real time.
  • COPY requirements.txt . copies the requirements file first so Docker can cache the dependency install layer.
  • COPY . . copies the rest of your project into the image.
  • CMD starts Gunicorn with 2 worker processes, serving your Django app on port 8000.

Why Gunicorn instead of the Django dev server?

The Django dev server (manage.py runserver) is single-threaded and designed for development only. Gunicorn is a production-grade WSGI server that handles concurrent requests across multiple worker processes.

This is what practitioners actually deploy behind a load balancer in production environments.

  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile printed in your terminal output.

Don't see the file?

  • Make sure the filename is exactly Dockerfile with a capital D and no file extension.
  • Confirm you created it in the same directory as requirements.txt, not inside a subfolder.

Help me troubleshoot my Dockerfile creation

✔️ Awesome, I've got everything!

Great. Double check you've saved Dockerfile and move on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]
Create the Docker Compose file

Docker Compose lets you define and run multi-container applications with a single YAML file. Even with one service, it simplifies how you pass environment variables and mount volumes.

  • Create a new file called docker-compose.yml in the same folder as your Dockerfile.
  • Paste the following code into docker-compose.yml:
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SECRET_KEY=change-me-in-production
      - DJANGO_DEBUG=False
      - DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
    volumes:
      - ./ml_artifacts:/app/ml_artifacts

What does this compose file do?

  • build: . tells Compose to build the image using your Dockerfile in the current directory.
  • ports maps port 8000 inside the container to port 8000 on your machine.
  • environment sets Django config values. Notice DJANGO_DEBUG=False turns off debug mode for a production-like setup.
  • volumes mounts your local ml_artifacts folder into the container so the model file stays up to date without rebuilding.
  • Save docker-compose.yml.
  • Confirm the file exists by running:
ls docker-compose.yml

You should see docker-compose.yml printed in the output.

File not showing up?

  • Make sure the filename is exactly docker-compose.yml (not .yaml).
  • Confirm you saved it in the same directory as your Dockerfile and requirements.txt.

Help me troubleshoot my docker-compose.yml setup

✔️ Awesome, I've got everything!

Great. Double check you've saved docker-compose.yml and move on.

ⓧ I'd like to double check the full code

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SECRET_KEY=change-me-in-production
      - DJANGO_DEBUG=False
      - DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
    volumes:
      - ./ml_artifacts:/app/ml_artifacts
Build the image and run the container

With both Docker files in place, you can now build the image and start the container. This packages everything into a portable unit that runs identically on any machine with Docker installed.

  • If you still have the Django dev server running from the previous step, stop it now by pressing Ctrl+C in that terminal. Port 8000 must be free for the container.
  • Build your Docker image by running this command:
docker build -t house-price-api .

What does this command do?

docker build reads your Dockerfile and executes each instruction to create an image. The -t house-price-api flag tags the image with a name you can reference later. The . at the end tells Docker to use the current directory as the build context.

The build takes a minute or two as Docker installs all your Python dependencies. You should see output ending with a line confirming the image was tagged.

Build failing?

  • Make sure Docker Desktop is running (check for the Docker whale icon in your menu bar or system tray).
  • If you see a permissions error, try prefixing the command with sudo on Linux.
  • If a package fails to install, double-check that your requirements.txt has no typos or extra whitespace.

Help me debug my Docker build failure

  • Start the container using Docker Compose by running:
docker compose up -d

What does this command do?

docker compose up reads your docker-compose.yml and starts all defined services. The -d flag runs it in detached mode so your terminal stays free.

  • Wait a few seconds for Gunicorn to start, then verify the prediction endpoint works from outside the container by running:
curl -X POST http://localhost:8000/api/v1/predict/ \
  -H "Content-Type: application/json" \
  -d '{"GrLivArea": 1500, "OverallQual": 7, "YearBuilt": 2005, "TotalBsmtSF": 1000, "GarageCars": 2, "Neighborhood": "CollgCr", "HouseStyle": "1Story"}'

You should see a JSON response with a predicted_price, model_version, and latency_ms field. This prediction is being served from inside the Docker container.

  • Also verify the health check endpoint by running:
curl http://localhost:8000/api/v1/health/

You should see {"status":"healthy","model_loaded":true} confirming the model loaded inside the container.

Getting a connection refused error?

  • Make sure no other process is using port 8000. If the Django dev server is still running, stop it with Ctrl+C.
  • Check that the container is actually running by running docker compose ps in your terminal.
  • If the container exited, check its logs with docker compose logs api to see what went wrong.

Help me debug my Docker container connection issue

Your ML prediction API is now containerized and serving predictions from Docker. Next up, you'll add a CI pipeline with GitHub Actions to automatically test your code on every push.

Add GitHub Actions CI Pipeline

Your prediction API is containerized and running locally. The next challenge is making sure your code stays healthy as you make changes.

CI/CD (Continuous Integration / Continuous Delivery) automates testing every time you push code. In this step, you will create a GitHub Actions workflow that runs your test suite and verifies the Docker build on every push to main.

In this step, get ready to:
  • Create a GitHub Actions workflow file that runs pytest automatically.
  • Add a Docker build verification job to the pipeline.
  • Push your project to GitHub and confirm the workflow passes.
Create the CI workflow file

GitHub Actions looks for workflow definitions inside the .github/workflows directory of your repository. Each .yml file in that directory is a separate workflow.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory exists by running:
ls .github

You should see workflows in the output.

  • Create a new file called ci.yml inside .github/workflows/ and paste the following code:
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest

What does this workflow do?

  • name: CI gives your workflow a name that appears in the GitHub Actions tab.
  • on: push/pull_request triggers the workflow whenever code is pushed or a PR is opened against the main branch.
  • runs-on: ubuntu-latest runs the job on a fresh Ubuntu virtual machine provided by GitHub.
  • actions/checkout@v6 clones your repository onto the runner so subsequent steps can access your code.
  • actions/setup-python@v5 installs Python 3.12 on the runner.
  • The final step runs pytest to execute your test suite.
Add a Docker build verification job

A passing test suite is only half the story. You also want to confirm the Docker image builds successfully after every change.

  • In .github/workflows/ci.yml, add the following docker job below the test job (at the same indentation level as test:):
  docker:
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v6

      - name: Build Docker image
        run: docker build -t house-price-api .

What does this job do?

  • needs: test means the docker job only runs after the test job passes. If tests fail, the Docker build is skipped.
  • docker build -t house-price-api . builds the image using your Dockerfile. If any step in the build fails (bad dependency, syntax error), the workflow fails too.
  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before continuing.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/ci.yml file:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest

  docker:
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v6

      - name: Build Docker image
        run: docker build -t house-price-api .

YAML indentation issues?

YAML is strict about indentation. Make sure docker: is at the same indent level as test: (two spaces under jobs:). Each steps: item uses a - dash prefix at four spaces.

If in doubt, copy the full code from the double-check tab above and replace your file entirely.

help me fix my ci.yml YAML indentation

Push to GitHub and verify the workflow passes

The workflow file exists locally but GitHub Actions only triggers when code is pushed to a remote repository. You need to create a GitHub repository and push your code.

  • Go to github.com/new in your browser.
  • Enter house-price-api as the repository name.
  • Leave the repository set to **Public** (or choose **Private** if you prefer).
  • Do not check any boxes for README, .gitignore, or license.
  • Click **Create repository**.

Why an empty repository?

You already have a local Git repo with commits. Creating an empty remote avoids merge conflicts when you push your existing history.

  • Back in your terminal, connect your local repository to GitHub and push by running these commands:
git add .
git commit -m "Add CI pipeline"
git branch -M main
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/house-price-api.git"]]
git push -u origin main

What do these commands do?

  • git add . stages all files for commit.
  • git commit creates a snapshot of your project.
  • git branch -M main renames your current branch to main (GitHub's default).
  • git remote add origin links your local repo to the GitHub remote.
  • git push -u origin main uploads your code to GitHub and sets the upstream tracking branch.

Authentication error when pushing?

GitHub no longer accepts passwords for command-line pushes. You need either a personal access token or SSH key configured. If prompted for credentials, generate a token at github.com/settings/tokens and use it as your password.

help me authenticate with GitHub from the command line

  • In your browser, navigate to your repository on GitHub.
  • Click the **Actions** tab at the top of the repository.
  • You should see a workflow run named "CI" with a green checkmark. Click on it to see the details.

The workflow runs two jobs in sequence. The test job installs dependencies and runs pytest. Once that passes, the docker job builds your Docker image.

Workflow not appearing in the Actions tab?

Make sure your .github/workflows/ci.yml file was included in your commit. Run git status to check for untracked files. If the file was missed, stage it, commit, and push again.

help me figure out why my GitHub Actions workflow isn't running

Your ML prediction API now has automated quality gates. Every push to main triggers tests and a Docker build verification. This is the foundation of production-grade ML engineering.

Secret mission

Add Model Versioning and Prediction Logging

Your API serves predictions, but has no memory of them. In production, every ML system needs an audit trail. Can you add prediction logging so every request is tracked with its inputs, output, latency, and model version?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Docker containers and images (local only, no cost).
  • Project folder on your Desktop.
  • GitHub repository.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your prediction API.

  • Your Docker container continues serving predictions on port 8000.
  • Your GitHub Actions workflow runs automatically on each push to main.
  • Your virtual environment and project files remain on your Desktop.

Pause - I'll come back to this later

Shut down running processes to free up system memory. Your files and repository stay intact for when you return.

  • Stop the Docker container by running this command from your project folder:
  • Deactivate your virtual environment by running:
docker compose down

This stops and removes the container but keeps the image cached for a fast restart later.

deactivate

To resume later, reactivate your virtual environment and run docker compose up --build.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Remove Docker containers and images by running:
docker compose down --rmi all
  • Delete the project folder from your Desktop:

macOS

cd ~/Desktop
rm -rf house-price-api

Windows

cd ~\Desktop
rmdir /s /q house-price-api
  • Delete the GitHub repository:
  • Navigate to your repository on GitHub.
  • Click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Follow the confirmation prompts to permanently delete the repository.

Nice Work!

Nice Work!

You just built a production ML prediction API from scratch. You trained a model, served it through a REST endpoint, containerized everything with Docker, and wired up automated testing with CI/CD.

Here's what you learned:

  • Trained an XGBoost house price model inside a scikit-learn Pipeline that handles missing values, encodes categoricals, scales numerics, and predicts in one serializable artifact.
  • Built a Django REST Framework API with input validation via serializers, a prediction endpoint, and a health check.
  • Containerized the system with Docker and Gunicorn, then created a GitHub Actions CI pipeline that runs tests and verifies the image build on every push.
  • Secret Mission: Added model versioning and prediction logging with a PredictionLog model and a /api/v1/predictions/history/ endpoint for production observability.

Ready to quiz yourself?