House Price Prediction API
Build a Dockerized ML prediction API with a CI/CD pipeline.
Introduction
30 Second Summary
Every day, thousands of machine learning models sit trapped in Jupyter notebooks. They can predict house prices, detect fraud, or recommend products. But without a way to serve predictions on demand, they never reach real users.
In this project, you will take a house price prediction model from training to production. You will build an API that serves predictions, package it in a container, and automate its delivery with a CI/CD pipeline.
What You'll Build
A complete ML prediction service that accepts housing features and returns a predicted price, served through a Django REST Framework API, containerized with Docker, and delivered automatically via GitHub Actions.
By the end of this project, you'll have:
- A trained Random Forest model with a scikit-learn Pipeline that prevents data leakage during preprocessing.
- A REST API endpoint that accepts JSON input and returns real-time house price predictions.
- A CI/CD pipeline that automatically tests your code, builds a production Docker image, and pushes it to GitHub Container Registry on every push to main.
- Secret Mission: a model introspection endpoint and request logging for production observability.
Are there any prerequisites?
You should be comfortable with basic Python syntax and running commands in a terminal. No prior experience with Django, Docker, or machine learning is required.
Before We Start
This project takes a machine learning model from a script all the way to a production-ready API with automated delivery. To get there, you need three core tools: Python for training the model and building the API, Docker for packaging everything into a portable container, and Git for version control.
In this step, you will install and verify each tool, then create the project folder with a clean virtual environment.
In this step, get ready to:
- Install Python 3.12 and verify it works.
- Install Docker and Docker Compose and verify both.
- Create the project directory with a Git repo and Python virtual environment.
Install Python 3.12
Python 3.12 is required because the ML libraries this project uses (scikit-learn 1.8.0) need Python 3.11 or higher.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.12 or higher
You are all set. Move on to the next substep.
ⓧ I see an older version
Your system has an older Python installed. On macOS, the default python3 often resolves to an older version even after you install a newer one.
- Download the Python 3.12 installer from the official site:
- python.org/downloads
- Run the installer and follow the prompts. On macOS, download the "macOS 64-bit universal2 installer". On Windows, download the "Windows installer (64-bit)" and check Add Python to PATH during installation.
- After installation, close and reopen your terminal, then run python3 --version again.
- If macOS still shows an older version, try running python3.12 --version to confirm the new binary is available. You can use python3.12 throughout this project if python3 does not resolve to 3.12.
Still seeing the old version?
On macOS, the system Python at /usr/bin/python3 may shadow a newer install. Use the full path /usr/local/bin/python3.12 or install via a version manager like pyenv.
help me get Python 3.12 working on my machine
ⓧ Command not found
Python is not installed on your system yet.
- Download the Python 3.12 installer from the official site:
- python.org/downloads
- On macOS, download the "macOS 64-bit universal2 installer" and run it.
- On Windows, download the "Windows installer (64-bit)". During installation, check the Add Python to PATH checkbox before clicking Install Now.
- Close and reopen your terminal after installation, then run python3 --version again. You should see 3.12 or higher.
Still getting command not found?
On Windows, try python --version (without the 3) since the Windows installer registers the command as python. Make sure you restarted your terminal after installation.
help me install Python 3.12
Install Docker and Docker Compose
Docker packages your application into a container that runs the same way on any machine. Docker Compose lets you define and run the container with a single command.
- Install Docker Desktop, which includes both Docker and Docker Compose. Download it from the official site:
- docs.docker.com/get-started/get-docker
- Choose the installer for your operating system (macOS, Windows, or Linux) and follow the installation prompts.
- After installation, open Docker Desktop to finish the setup. It may ask you to accept the Docker Subscription Service Agreement.
- Verify Docker is installed by running this command:
docker --version
✔️ I see a version number
Docker is installed. Now verify Docker Compose.
ⓧ Command not found
Docker is not installed or not in your PATH.
- Make sure you downloaded and installed Docker Desktop from docs.docker.com/get-started/get-docker.
- On macOS, launch Docker Desktop from your Applications folder. On Windows, launch it from the Start menu.
- Close and reopen your terminal after Docker Desktop finishes starting up, then try the command again.
Docker Desktop won't start?
On Windows, Docker Desktop requires WSL 2 or Hyper-V. Check the Docker Desktop docs for your platform's prerequisites.
help me get Docker working
- Verify Docker Compose by running this command:
docker compose version
✔️ I see a version number
Docker Compose is ready. You have both tools installed.
ⓧ Command not found
Docker Compose V2 is bundled with Docker Desktop. If you see this error, Docker Desktop may not be running.
- Open Docker Desktop and wait for the engine to start (the whale icon in your system tray or menu bar should stop animating).
- Close and reopen your terminal, then try the command again.
Still not working?
Make sure you have a recent version of Docker Desktop. Older standalone Docker Engine installations may not include docker compose as a subcommand.
help me fix Docker Compose
Create the project directory and virtual environment
With your tools installed, you can create the project folder, initialize Git for version control, and set up an isolated Python virtual environment.
- 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
- Initialize a Git repository by running this command:
git init
What does git init do?
Git tracks every change you make to your code. Running git init sets up a new repository in the current folder so you can commit changes and later push them to GitHub.
- Create a Python virtual environment by running this command:
python3 -m venv .venv
What is a virtual environment?
A virtual environment is an isolated Python installation specific to this project. It keeps your project's packages separate from your system Python, preventing version conflicts between projects.
- Activate the virtual environment:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (.venv) in your prompt?
On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell and try activating again.
On macOS, make sure you ran source (not just the path). If you used python3.12 earlier, use python3.12 -m venv .venv to recreate the environment.
help me activate my virtual environment
- Confirm the project files exist by running:
ls -a
You should see .git and .venv in the output. These are your Git repository and virtual environment.
- Open VS Code in this directory. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor if you prefer Cursor) 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.
Trust the workspace
VS Code may ask if you trust the authors of this folder. Click Yes, I trust the authors to enable all editor features.
Your environment is ready. Next up, you will train a machine learning model on real housing data and save it for your API to use.
Set Up the Project
Your tools are installed and your virtual environment is active. Now it's time to give the project its shape.
A production ML service has several moving parts: a trained model, an API layer, tests, and a deployment pipeline. In this step, you'll create the folder structure that keeps these concerns separated, then install and pin every dependency the project needs.
In this step, get ready to:
- Create the project's directory structure.
- Install and pin all Python dependencies.
- Configure pytest for Django testing.
Create the project directory structure
Each top-level folder isolates one responsibility. model/ stores the trained model artifact. predictions/ and house_price_api/ hold the Django app and project configuration. tests/ contains automated tests. .github/workflows/ is where the CI/CD pipeline definition lives.
- Create all the directories your project needs by running this command:
mkdir -p model predictions house_price_api tests .github/workflows
- Confirm the directories exist by running:
ls -a
You should see model, predictions, house_price_api, tests, and .github in the output.
Don't see all the folders?
Make sure you included the -p flag. Without it, .github/workflows/ fails because the parent .github/ directory doesn't exist yet.
help me create nested directories with mkdir
Create requirements.txt and install dependencies
Pinning every dependency to an exact version ensures your project builds identically on any machine. This prevents the "works on my machine" problem that haunts production deployments.
- Create a new file called requirements.txt in the root of your project folder.
- In VS Code, click File in the top menu bar, then New File.
- Name the file requirements.txt and save it to the project root.
- Paste the following content into requirements.txt:
Django==5.2.14
djangorestframework==3.17.1
gunicorn==26.0.0
scikit-learn==1.8.0
joblib==1.4.2
numpy==2.2.6
pytest==8.3.5
pytest-django==4.9.0
What does this file do?
- Django and Django REST Framework power the API layer that serves predictions.
- scikit-learn provides the machine learning pipeline and model training.
- Gunicorn is the production-grade WSGI server that replaces Django's built-in development server.
- joblib serializes trained models efficiently. numpy handles numerical array operations.
- pytest and pytest-django run automated tests against the Django API.
- Save requirements.txt.
- Install all the dependencies into your virtual environment by running:
pip install -r requirements.txt
This takes a minute or two. When it finishes, you should see a Successfully installed message listing all the packages.
Seeing installation errors?
Make sure your virtual environment is active. You should see the environment name in parentheses at the start of your terminal prompt.
If you see permission errors, do not use sudo. Deactivate and reactivate your virtual environment instead.
help me fix pip install errors in my virtual environment
✔️ Awesome, I've got everything!
Great. Double check that you've saved requirements.txt and that the install completed without errors.
ⓧ I'd like to double check the full code
Django==5.2.14
djangorestframework==3.17.1
gunicorn==26.0.0
scikit-learn==1.8.0
joblib==1.4.2
numpy==2.2.6
pytest==8.3.5
pytest-django==4.9.0
Configure pytest for Django
pytest needs to know which Django settings module to use when it runs. Without this configuration, test discovery fails because Django hasn't been initialized.
- Create a new file called pytest.ini in the root of your project folder.
- Paste the following content into pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = house_price_api.settings
python_files = tests/*.py
What does this configuration do?
- DJANGO_SETTINGS_MODULE tells pytest where to find your Django project's settings file.
- python_files = tests/*.py tells pytest to look for test files inside the tests/ directory.
- Save pytest.ini.
You won't see a change yet. This file will be used when you run tests in a later step.
✔️ Awesome, I've got everything!
Great. Double check that you've saved pytest.ini in the project root alongside requirements.txt.
ⓧ I'd like to double check the full code
[pytest]
DJANGO_SETTINGS_MODULE = house_price_api.settings
python_files = tests/*.py
Your project skeleton is in place and every dependency is locked down. Next up, you'll train the machine learning model that powers the prediction API.
Train the ML Model
Your environment is ready. Now it is time to build the brain of your prediction service.
Every ML service starts with a trained model. In this step, you will load a real housing dataset, build a preprocessing pipeline that prevents data leakage, and save the trained model to disk so your API can serve predictions later.
In this step, get ready to:
- Load the California Housing dataset and split it into training and test sets.
- Build a scikit-learn Pipeline that chains preprocessing with a Random Forest model.
- Train, evaluate, and save the model artifact to disk.
Load the California Housing dataset
The California Housing dataset is a regression benchmark built into scikit-learn. It contains 20,640 samples with 8 features like median income, house age, and average number of rooms. The target variable is median house value in units of $100,000.
- Create a new file called train_model.py in the root of your project folder (the same level as requirements.txt).
- Add the following imports and dataset loading code:
import joblib
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def train():
# Load the California Housing dataset
housing = fetch_california_housing()
X, y = housing.data, housing.target
feature_names = housing.feature_names
# Split into training (80%) and test (20%) sets
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
What does this code do?
The imports bring in everything you need for the full training pipeline. fetch_california_housing() downloads the dataset directly from scikit-learn, so no external files are needed.
train_test_split reserves 20% of the data for testing. The random_state=42 ensures you get the same split every time, making results reproducible.
You won't see a change yet. We will run this script after adding the evaluation code.
Build the preprocessing pipeline
A Pipeline chains your preprocessing steps and model into a single object. This prevents data leakage because the scaler learns its parameters only from training data, never from test data.
- In train_model.py, add the following code directly below the train_test_split call (still inside the train() function):
# Build preprocessing + model pipeline
pipeline = Pipeline(
steps=[
("scaler", StandardScaler()),
("regressor", RandomForestRegressor(n_estimators=100, random_state=42)),
]
)
# Train the pipeline on training data
pipeline.fit(X_train, y_train)
What is a scikit-learn Pipeline?
StandardScaler normalizes features to have zero mean and unit variance. RandomForestRegressor is an ensemble of 100 decision trees that votes on the final prediction.
Wrapping both in a Pipeline means when you call .fit(), the scaler fits on training data first, then passes the transformed data to the model. When you call .predict() later, the same scaler transformation is applied automatically.
You won't see a change yet. The next chunk adds evaluation and runs the script.
Evaluate and save the trained model
After training, you need to measure how well the model performs and save it so your API can load it later. You will use two metrics: R2 score (how much variance the model explains) and RMSE (average prediction error).
- In train_model.py, add the following code directly below pipeline.fit(X_train, y_train) (still inside the train() function):
# Evaluate model performance
y_pred = pipeline.predict(X_test)
r2 = r2_score(y_test, y_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
print(f"R2 Score: {r2:.4f}")
print(f"RMSE: {rmse:.4f}")
# Package model with metadata for the API
model_artifact = {
"pipeline": pipeline,
"feature_names": list(feature_names),
"r2_score": round(r2, 4),
"rmse": round(rmse, 4),
}
# Save to disk for the API to load later
joblib.dump(model_artifact, "model/house_price_model.joblib")
print("Model saved to model/house_price_model.joblib")
if __name__ == "__main__":
train()
What does this code do?
The evaluation section runs predictions on the held-out test set and calculates two metrics. R2 score tells you how much variance the model captures (closer to 1.0 is better). RMSE tells you the average error in the same units as the target.
The model_artifact dictionary bundles the trained pipeline with metadata (feature names, scores). joblib serializes this to a single file that your Django API will load at startup.
The if __name__ == "__main__" guard ensures the train() function only runs when you execute the script directly, not when it is imported.
- Save train_model.py.
- Run the training script by running this command:
python train_model.py
You should see output showing an R2 score around 0.80 and an RMSE value, followed by a confirmation that the model was saved.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. You should see (venv) at the beginning of your terminal prompt. If not, activate it and try again.
If you see an error about sklearn not being found, re-run pip install -r requirements.txt to install the dependencies.
help me debug a ModuleNotFoundError when running train_model.py
Why joblib instead of pickle?
joblib is optimized for serializing objects that contain large NumPy arrays, like trained scikit-learn models. It is faster and more memory-efficient than Python's built-in pickle for this use case.
✔️ Awesome, I've got everything!
Great. Double check you have saved train_model.py and confirmed the model file exists in your model/ directory.
ⓧ I'd like to double check the full code
import joblib
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def train():
housing = fetch_california_housing()
X, y = housing.data, housing.target
feature_names = housing.feature_names
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
pipeline = Pipeline(
steps=[
("scaler", StandardScaler()),
("regressor", RandomForestRegressor(n_estimators=100, random_state=42)),
]
)
pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_test)
r2 = r2_score(y_test, y_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
print(f"R2 Score: {r2:.4f}")
print(f"RMSE: {rmse:.4f}")
model_artifact = {
"pipeline": pipeline,
"feature_names": list(feature_names),
"r2_score": round(r2, 4),
"rmse": round(rmse, 4),
}
joblib.dump(model_artifact, "model/house_price_model.joblib")
print("Model saved to model/house_price_model.joblib")
if __name__ == "__main__":
train()
Your model is trained and saved. Next up, you will build a Django REST API that loads this model and serves real-time predictions.
Build the Django REST API
Your trained model is sitting in a .joblib file, ready to make predictions. The problem is that nobody can use it yet.
To make predictions accessible to other applications, you need a web API that accepts input features over HTTP and returns a predicted price. In this step, you will build that API using Django REST Framework, wire up prediction and health-check endpoints, and write automated tests to verify everything works.
In this step, get ready to:
- Create a Django project and a predictions app.
- Configure Django REST Framework for JSON-only responses.
- Build a prediction endpoint and verify it with automated tests.
Create a Django project and predictions app
Django organizes code into a top-level project (configuration and routing) and one or more apps (feature modules). You need both before writing any API logic.
- Scaffold the Django project inside your current directory by running this command:
django-admin startproject house_price_api .
Why the trailing dot?
The . at the end tells Django to create the project files in your current folder. Without it, Django would create a nested house_price_api/house_price_api/ structure.
- Confirm the project was created by running:
ls
You should see manage.py and a house_price_api/ directory in the output.
- Create the predictions app by running:
python manage.py startapp predictions
- Create a tests directory for your API tests by running:
mkdir tests
- Confirm your directory structure by running:
ls
You should see house_price_api/, manage.py, model/, predictions/, tests/, and train_model.py in the output.
Command not found?
If django-admin is not recognized, your virtual environment may not be active. Run source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows) and try again.
help me troubleshoot why django-admin is not found after activating my virtual environment
Configure Django REST Framework
The auto-generated settings.py includes many features you do not need for a stateless prediction API (admin panel, sessions, auth). You will replace it with a lean, production-ready configuration.
- Open house_price_api/settings.py in your editor.
- Select all the existing content and replace it with the following:
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY", "dev-insecure-key-change-in-production"
)
DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.staticfiles",
"rest_framework",
"predictions",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
]
ROOT_URLCONF = "house_price_api.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
],
},
},
]
WSGI_APPLICATION = "house_price_api.wsgi.application"
DATABASES = {}
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
What does this configuration do?
SECRET_KEY reads from an environment variable with a fallback for local development. DEBUG is off by default and only turns on when explicitly set.
INSTALLED_APPS registers rest_framework (for API views) and predictions (your app). Unused apps like admin and auth are removed.
DATABASES = {} tells Django no database is needed. This is a stateless prediction API with no persistence requirement.
REST_FRAMEWORK restricts output to JSON only, since this API has no browser interface.
- Save house_price_api/settings.py.
Seeing an import error later?
If Django complains about a missing app, double-check that "predictions" is in the INSTALLED_APPS list with the correct spelling. Also verify "rest_framework" is listed (underscore, not hyphen).
help me fix Django INSTALLED_APPS configuration errors
Build the prediction view and wire up routes
The prediction view is where your trained model meets incoming HTTP requests. It loads the model once at startup, then uses it to serve predictions on every request.
Why load the model at module level?
Loading the .joblib file takes time (reading from disk, deserializing numpy arrays). By loading it once when the module imports, you avoid repeating that cost on every single API request.
- Open predictions/views.py in your editor.
- Select all the existing content and replace it with the following imports and model-loading code:
import joblib
import numpy as np
from pathlib import Path
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
MODEL_PATH = Path(__file__).resolve().parent.parent / "model" / "house_price_model.joblib"
model_artifact = joblib.load(MODEL_PATH)
pipeline = model_artifact["pipeline"]
feature_names = model_artifact["feature_names"]
What does this code do?
MODEL_PATH builds an absolute path from the views file up to the project root, then into model/house_price_model.joblib.
joblib.load deserializes the artifact dictionary. You then extract the pipeline (for predictions) and feature_names (for input validation) into module-level variables.
- Below the model-loading code, add the PredictView and HealthCheckView classes:
class PredictView(APIView):
def post(self, request):
data = request.data
if "features" not in data:
return Response(
{"error": "Missing 'features' key in request body"},
status=status.HTTP_400_BAD_REQUEST,
)
features = data["features"]
if not isinstance(features, list) or len(features) != len(feature_names):
return Response(
{
"error": f"Expected a list of {len(feature_names)} features: {feature_names}"
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
input_array = np.array(features, dtype=float).reshape(1, -1)
except (ValueError, TypeError):
return Response(
{"error": "All feature values must be numeric"},
status=status.HTTP_400_BAD_REQUEST,
)
prediction = pipeline.predict(input_array)[0]
return Response(
{
"predicted_price": round(float(prediction) * 100000, 2),
"unit": "USD",
"features_used": feature_names,
}
)
class HealthCheckView(APIView):
def get(self, request):
return Response({"status": "healthy", "model_loaded": True})
What does PredictView do?
PredictView handles POST requests with three layers of validation. First, it checks that a features key exists. Then it verifies the list has exactly 8 values (one per feature). Finally, it confirms all values are numeric.
If validation passes, the pipeline scales the input and predicts a price. The result is multiplied by 100,000 because the California Housing dataset stores prices in units of $100k.
HealthCheckView is a simple GET endpoint that confirms the API is running and the model loaded successfully. This is useful for Docker health checks and load balancers.
- Save predictions/views.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
import joblib
import numpy as np
from pathlib import Path
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
MODEL_PATH = Path(__file__).resolve().parent.parent / "model" / "house_price_model.joblib"
model_artifact = joblib.load(MODEL_PATH)
pipeline = model_artifact["pipeline"]
feature_names = model_artifact["feature_names"]
class PredictView(APIView):
def post(self, request):
data = request.data
if "features" not in data:
return Response(
{"error": "Missing 'features' key in request body"},
status=status.HTTP_400_BAD_REQUEST,
)
features = data["features"]
if not isinstance(features, list) or len(features) != len(feature_names):
return Response(
{
"error": f"Expected a list of {len(feature_names)} features: {feature_names}"
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
input_array = np.array(features, dtype=float).reshape(1, -1)
except (ValueError, TypeError):
return Response(
{"error": "All feature values must be numeric"},
status=status.HTTP_400_BAD_REQUEST,
)
prediction = pipeline.predict(input_array)[0]
return Response(
{
"predicted_price": round(float(prediction) * 100000, 2),
"unit": "USD",
"features_used": feature_names,
}
)
class HealthCheckView(APIView):
def get(self, request):
return Response({"status": "healthy", "model_loaded": True})
- Now open house_price_api/urls.py in your editor.
- Select all the existing content and replace it with the following route configuration:
from django.urls import path
from predictions.views import HealthCheckView, PredictView
urlpatterns = [
path("api/predict/", PredictView.as_view(), name="predict"),
path("api/health/", HealthCheckView.as_view(), name="health"),
]
What does this code do?
This maps two URL paths to your views. /api/predict/ routes to PredictView (POST requests with features). /api/health/ routes to HealthCheckView (GET requests for status).
- Save house_price_api/urls.py.
Now you will write automated tests to verify both endpoints work correctly.
- Create a new file called test_api.py inside your tests/ directory.
- Add the following test for the health endpoint:
import pytest
from django.test import TestCase
from rest_framework.test import APIClient
class TestHealthEndpoint(TestCase):
def setUp(self):
self.client = APIClient()
def test_health_check_returns_200(self):
response = self.client.get("/api/health/")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
assert response.json()["model_loaded"] is True
What does this test do?
APIClient from DRF simulates HTTP requests without starting a real server. The test sends a GET to /api/health/ and asserts the response has status 200, a "healthy" status field, and model_loaded set to True.
- Below the TestHealthEndpoint class, add the prediction endpoint tests:
class TestPredictEndpoint(TestCase):
def setUp(self):
self.client = APIClient()
def test_predict_returns_price(self):
payload = {
"features": [8.3252, 41.0, 6.984, 1.024, 322.0, 2.556, 37.88, -122.23]
}
response = self.client.post("/api/predict/", payload, format="json")
assert response.status_code == 200
assert "predicted_price" in response.json()
assert response.json()["unit"] == "USD"
def test_predict_missing_features_returns_400(self):
response = self.client.post("/api/predict/", {}, format="json")
assert response.status_code == 400
def test_predict_wrong_feature_count_returns_400(self):
payload = {"features": [1.0, 2.0]}
response = self.client.post("/api/predict/", payload, format="json")
assert response.status_code == 400
What do these tests cover?
test_predict_returns_price sends valid 8-feature input and checks for a 200 response with a price in USD.
test_predict_missing_features_returns_400 sends an empty body and expects a 400 error.
test_predict_wrong_feature_count_returns_400 sends only 2 features instead of 8 and expects a 400 error. Together, these validate both the happy path and your input validation logic.
- Save tests/test_api.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
import pytest
from django.test import TestCase
from rest_framework.test import APIClient
class TestHealthEndpoint(TestCase):
def setUp(self):
self.client = APIClient()
def test_health_check_returns_200(self):
response = self.client.get("/api/health/")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
assert response.json()["model_loaded"] is True
class TestPredictEndpoint(TestCase):
def setUp(self):
self.client = APIClient()
def test_predict_returns_price(self):
payload = {
"features": [8.3252, 41.0, 6.984, 1.024, 322.0, 2.556, 37.88, -122.23]
}
response = self.client.post("/api/predict/", payload, format="json")
assert response.status_code == 200
assert "predicted_price" in response.json()
assert response.json()["unit"] == "USD"
def test_predict_missing_features_returns_400(self):
response = self.client.post("/api/predict/", {}, format="json")
assert response.status_code == 400
def test_predict_wrong_feature_count_returns_400(self):
payload = {"features": [1.0, 2.0]}
response = self.client.post("/api/predict/", payload, format="json")
assert response.status_code == 400
- Run your test suite to verify everything is wired up correctly:
pytest tests/ -v
You should see all 4 tests passing with green output.
Tests failing?
If you see ModuleNotFoundError: No module named 'predictions', check that "predictions" is in INSTALLED_APPS in settings.py.
If you see FileNotFoundError for the model file, make sure model/house_price_model.joblib exists. Run python train_model.py again if needed.
help me debug failing pytest tests for my Django REST Framework prediction API
- Start the Django development server to try the API manually:
python manage.py runserver
You should see output saying the server is running at http://127.0.0.1:8000/.
- Open a new terminal tab and test the health endpoint by running:
curl http://127.0.0.1:8000/api/health/
You should see {"status":"healthy","model_loaded":true} in the response.
- Test the prediction endpoint by running:
curl -X POST http://127.0.0.1:8000/api/predict/ \
-H "Content-Type: application/json" \
-d '{"features": [8.3252, 41.0, 6.984, 1.024, 322.0, 2.556, 37.88, -122.23]}'
You should see a JSON response with predicted_price, unit, and features_used fields.
- Stop the dev server by pressing Ctrl+C in the terminal where it is running.
Seeing a connection refused error?
Make sure the dev server is still running in the other terminal tab. If you see a Django error page about ALLOWED_HOSTS, verify that localhost and 127.0.0.1 are in the ALLOWED_HOSTS setting in settings.py.
help me troubleshoot my Django REST API not responding to curl requests
Your prediction API is live and tested. Next up, you will containerize the entire application with Docker so it runs identically on any machine.
Containerize with Docker
Your Django REST API works locally, but it depends on your machine's Python version, installed packages, and virtual environment. If you hand this project to a teammate or deploy it to a server, they'd need to replicate your exact setup.
Docker solves this by packaging your application, its dependencies, and its runtime into a single portable image. In this step, you'll containerize your prediction API using a multi-stage Dockerfile and run it with Docker Compose.
In this step, get ready to:
- Write a multi-stage Dockerfile with a production-ready Gunicorn server.
- Create a Docker Compose file for one-command deployment.
- Build, run, and test your containerized API.
Write the multi-stage Dockerfile
Before writing the Dockerfile, you need a .dockerignore file. This tells Docker which files to exclude from the build context, keeping the image lean and avoiding leaking sensitive files.
- Create a .dockerignore file in your project root by running:
touch .dockerignore
- Open .dockerignore and add the following content:
__pycache__
*.pyc
.git
.env
.venv
venv
*.egg-info
.pytest_cache
.github
What does this file do?
- __pycache__ and *.pyc exclude compiled Python bytecode files.
- .git excludes your entire Git history from the image.
- .env, .venv, and venv keep secrets and local virtual environments out.
- .github excludes your CI/CD workflow files (you'll create these next step).
- Save .dockerignore.
- Confirm the file exists by running:
ls -a .dockerignore
You should see .dockerignore in the output.
Now for the main event. A multi-stage Docker build uses two separate FROM instructions to separate build-time dependencies from the final production image.
- Create a Dockerfile in your project root by running:
touch Dockerfile
- Open Dockerfile and add the following content:
# Stage 1: Builder
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim-bookworm AS production
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m -s /bin/bash appuser
COPY --from=builder /install /usr/local
COPY . .
RUN mkdir -p model && python train_model.py
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/')" || exit 1
CMD ["gunicorn", "house_price_api.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--timeout", "30", \
"--access-logfile", "-", \
"--error-logfile", "-"]
What does this Dockerfile do?
The builder stage installs build-essential (needed to compile C extensions for numpy and scikit-learn), then installs all Python packages into a separate /install directory.
The production stage starts fresh from the same slim base image. It copies only the pre-built packages from the builder stage, skipping the ~250MB of build tools entirely.
- A non-root appuser runs the application for security.
- train_model.py runs at build time so the model is baked into the image.
- The HEALTHCHECK calls your /api/health/ endpoint every 30 seconds to verify the container is alive.
- Gunicorn serves the app with 2 workers on port 8000, replacing Django's development server.
Why multi-stage over a single Dockerfile?
A single-stage build would leave build-essential and all its headers in the final image. Multi-stage builds let you throw away the compiler toolchain after using it, cutting the image size by roughly 50%.
In production, a smaller image means faster pulls, reduced attack surface, and lower storage costs.
- Save Dockerfile.
- Confirm it exists by running:
ls Dockerfile
You should see Dockerfile listed.
File not showing up?
- Make sure you created the file in your project root (the same folder containing manage.py), not inside a subfolder.
- The file must be named exactly Dockerfile with a capital D and no file extension.
help me find where my Dockerfile should go
✔️ Awesome, I've got everything!
Great. Make sure you've saved Dockerfile and .dockerignore before moving on.
ⓧ I'd like to double check the full code
Here's the complete Dockerfile:
# Stage 1: Builder
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim-bookworm AS production
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m -s /bin/bash appuser
COPY --from=builder /install /usr/local
COPY . .
RUN mkdir -p model && python train_model.py
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/')" || exit 1
CMD ["gunicorn", "house_price_api.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--timeout", "30", \
"--access-logfile", "-", \
"--error-logfile", "-"]
And here's the complete .dockerignore:
__pycache__
*.pyc
.git
.env
.venv
venv
*.egg-info
.pytest_cache
.github
Create the Docker Compose file
Docker Compose lets you define how to build and run your container in a declarative YAML file. Instead of typing long docker run commands with ports and environment variables, you define everything once and start it with a single command.
- Create a docker-compose.yml file in your project root by running:
touch docker-compose.yml
- Open docker-compose.yml and add the following content:
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DJANGO_SECRET_KEY=change-this-to-a-real-secret-key
- DEBUG=0
- DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
restart: unless-stopped
What does this file do?
- build: . tells Compose to build the image using the Dockerfile in the current directory.
- ports: "8000:8000" maps port 8000 inside the container to port 8000 on your machine.
- The environment block passes configuration to Django via environment variables (matching the os.environ.get calls in settings.py).
- restart: unless-stopped automatically restarts the container if it crashes.
- Save docker-compose.yml.
- Confirm it exists by running:
ls docker-compose.yml
You should see docker-compose.yml listed.
File not showing up?
- Double-check the filename is exactly docker-compose.yml (not docker-compose.yaml or compose.yml).
- Make sure it's in the same directory as your Dockerfile.
help me fix my docker-compose.yml location
✔️ Awesome, I've got everything!
Great. Make sure docker-compose.yml is saved before building.
ⓧ I'd like to double check the full code
Here's the complete docker-compose.yml:
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DJANGO_SECRET_KEY=change-this-to-a-real-secret-key
- DEBUG=0
- DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
restart: unless-stopped
Build and run the container
Everything is configured. Time to build the Docker image and spin up your containerized API.
- Make sure Docker Desktop is running on your machine.
- Build and start the container in detached mode by running:
docker compose up -d --build
What does this command do?
- --build tells Compose to build the image from your Dockerfile before starting.
- -d runs the container in detached mode (in the background) so your terminal stays free.
The first build takes a few minutes because it downloads the base image, installs system packages, pip packages, and trains the model inside the container.
Once the build finishes, you should see output indicating the container is running.
- Verify the container is healthy by running:
docker compose ps
You should see your web service listed with a status of Up or running.
- Test the health endpoint by running:
curl http://localhost:8000/api/health/
You should see:
{"status":"healthy","model_loaded":true}
- Test the prediction endpoint by running:
curl -X POST http://localhost:8000/api/predict/ \
-H "Content-Type: application/json" \
-d '{"features": [8.3252, 41.0, 6.984, 1.024, 322.0, 2.556, 37.88, -122.23]}'
You should see a JSON response with a predicted_price value in USD, along with unit and features_used fields.
Getting "connection refused"?
- Make sure Docker Desktop is running.
- Run docker compose logs to check for build errors.
- If you see an error about port 8000 already in use, stop the Django development server you may have running from the previous step (press Ctrl+C in that terminal).
- If the build fails on pip install, make sure your requirements.txt is saved with the correct package versions.
help me debug my Docker container
Your ML prediction API is now running inside a production-ready Docker container. Next up, you'll automate testing and image delivery with a GitHub Actions CI/CD pipeline.
Add CI/CD with GitHub Actions
Your Dockerized API is running locally and serving predictions. The next challenge is automating the journey from code change to production-ready container.
In this step, you'll create a GitHub Actions workflow that automatically runs your tests and builds a Docker image every time you push to main. The image gets pushed to GitHub Container Registry (GHCR), making it available for deployment anywhere.
In this step, get ready to:
- Verify your tests pass locally before wiring them into CI.
- Create a GitHub Actions workflow with test and build-and-push jobs.
- Push to GitHub and watch the pipeline run end to end.
Run your tests locally
Before automating anything, you want to confirm your existing test suite passes. Your tests/test_api.py file already covers the health and prediction endpoints.
- Run the test suite by executing this command:
pytest tests/ -v
What does this command do?
The -v flag runs pytest in verbose mode so you can see each individual test name and its pass/fail status. The pytest.ini file you already have tells pytest which Django settings module to use.
- You should see all 4 tests passing with green output.
Tests failing locally?
Make sure your virtual environment is activated. If you see an import error for Django, run pip install -r requirements.txt again.
If you see a file-not-found error for the model, confirm that model/house_price_model.joblib exists. Re-run python train_model.py if needed.
Help me debug failing pytest tests for my Django prediction API.
Create the GitHub Actions workflow
A CI/CD pipeline automates the repetitive steps you would otherwise do by hand: run tests, build the Docker image, and push it to a registry. GitHub Actions reads workflow files from a special directory in your repository.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Confirm the directories were created by running:
ls .github/workflows
The output should show an empty directory (no error). This confirms the nested folder structure exists.
- Create a new file called ci.yml inside .github/workflows/. Add the workflow name, triggers, and the test job by pasting the following code:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run Tests
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Train model
run: |
mkdir -p model
python train_model.py
- name: Run tests
run: pytest tests/ -v
What does this code do?
The on: block triggers the workflow on every push or pull request to the main branch.
The test job runs on an ubuntu-24.04 runner. It checks out the code, installs Python 3.12 with pip caching, installs dependencies, trains the model fresh (since the binary .joblib file is not committed to Git), and runs the tests.
- Now add the build-and-push job below the test job in the same file:
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-24.04
needs: [test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
What does this code do?
The needs: [test] line means this job only runs after the test job passes. The if: condition ensures images are only pushed on direct pushes to main (not on pull requests).
The job logs into GHCR using the built-in GITHUB_TOKEN (no extra secrets needed), then builds and pushes the Docker image with two tags: latest and the commit SHA. The cache-from: type=gha line enables GitHub Actions layer caching so subsequent builds are faster.
Why GHCR instead of Docker Hub?
GitHub Container Registry is tightly integrated with GitHub. It uses the same GITHUB_TOKEN for authentication (no extra secrets to configure), is free for public repositories with no pull rate limits, and keeps your container image next to your source code.
- Save .github/workflows/ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run Tests
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Train model
run: |
mkdir -p model
python train_model.py
- name: Run tests
run: pytest tests/ -v
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-24.04
needs: [test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Push to GitHub and verify the pipeline
With the workflow file in place, you need to push your code to GitHub so the pipeline can trigger. You'll create a new repository, connect it as a remote, and push.
- Open your browser and go to github.com/new.
- Enter a repository name (e.g. house-price-api).
- Select **Public** visibility.
- Leave all checkboxes unchecked (no README, no .gitignore, no license). You already have a local repo.
- Click Create repository.
Why public visibility?
GHCR is free for public repositories with no pull rate limits. GitHub Actions is also free for public repos. Private repos get 2,000 free minutes per month.
- Back in your terminal, remove the .github entry from .dockerignore so it gets committed to Git. Open .dockerignore and find this line:
.github
- Leave it as-is. The .dockerignore file only affects Docker builds, not Git. Your .github folder will still be committed and pushed to GitHub.
- Stage all your files, commit, and push to GitHub by running these commands:
git add .
git commit -m "Add CI/CD pipeline with GitHub Actions"
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?
The git branch -M main renames your default branch to main (matching the workflow trigger). The git remote add origin connects your local repo to the GitHub remote. The -u flag sets up tracking so future pushes only need git push.
Authentication error on push?
GitHub no longer accepts passwords for Git operations. You need either a Personal Access Token (use it as your password when prompted) or SSH key authentication. Check your GitHub authentication settings if the push is rejected.
Help me fix a GitHub authentication error when pushing code.
- In your browser, navigate to your repository on GitHub.
- Click the Actions tab at the top of the repository page.
- Click on the workflow run named CI/CD Pipeline in the left sidebar.
- Click the most recent run to see its progress.
You should see two jobs in the visualization graph: Run Tests and Build and Push Docker Image. The second job only starts after the first one passes.
Workflow not appearing in the Actions tab?
Make sure the .github/workflows/ci.yml file was included in your commit. Run git log --stat locally and look for the file in the commit. If it's missing, stage it and push again.
Also confirm you pushed to the main branch. The workflow only triggers on pushes to main.
Help me troubleshoot why my GitHub Actions workflow is not running.
Your ML prediction service now has a fully automated delivery pipeline. Every push to main triggers tests, builds a production Docker image, and publishes it to GHCR. From notebook to production with automated delivery.
Secret mission
Add Model Introspection and Request Logging
Your prediction API works, but you have zero visibility into what's happening. You don't know what the model's performance metrics are, and you can't see what predictions are being served. Time to add observability with a model info endpoint and a prediction logger.
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:
- Project folder on your Desktop (all Python files, Django project, model artifacts).
- Docker containers, images, and the prediction_logs named volume.
- GitHub repository (source code and CI/CD workflow).
- GitHub Container Registry (GHCR) package (Docker images pushed by CI/CD).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the API.
- Your Docker container continues serving predictions on port 8000.
- Your GitHub repository stays intact for future pushes.
- Docker images remain in GHCR for pulling to other machines.
- Python 3.12 and Docker remain installed on your machine. These are general-purpose tools useful for future projects.
Pause - I'll come back to this later
Stop running containers and deactivate your virtual environment to free up system resources. Your files and images stay intact for when you return.
- Stop the Docker container by running this in your project folder:
- Deactivate your Python virtual environment by running:
docker compose down
This stops and removes the container but preserves your images and the prediction_logs volume.
deactivate
When you come back, run docker compose up -d to restart the service.
Delete - I don't want to use this again
Remove all project resources from your machine and GitHub. This cannot be undone.
- Stop containers, remove images, and delete the named volume by running this in your project folder:
docker compose down -v --rmi all
- Deactivate your virtual environment:
deactivate
- Delete the project folder from your Desktop. On macOS or Linux:
cd ~/Desktop
rm -rf house-price-api
On Windows:
cd ~\Desktop
Remove-Item -Recurse -Force house-price-api
- Delete the GitHub repository. Open your repository page on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository.
- Delete the GHCR package. On your repository page, click the Packages link in the right sidebar, select your package, click Package settings, then click Delete this package.
Nice Work!
Nice Work!
You just built a production-grade ML prediction service with automated delivery. From training a model to pushing a Docker image through a CI/CD pipeline, this is the full lifecycle of a real-world machine learning application.
Here's what you accomplished:
- Trained a Random Forest regression model using a scikit-learn Pipeline that bundles preprocessing and prediction into a single artifact, preventing data leakage.
- Built a Django REST Framework API with input validation that serves real-time house price predictions from a /api/predict/ endpoint.
- Containerized the application with a multi-stage Docker build served by Gunicorn, and automated testing and image delivery with a GitHub Actions CI/CD pipeline pushing to GitHub Container Registry.
- Secret Mission: Added a /api/model-info/ introspection endpoint and prediction request logging for observability.
Ready to quiz yourself?