How to Deploy a Machine Learning Model for Your Final-Year Project

Your model scores marks only if the examiner can run it. This guide walks through the five deployment paths students actually use — Streamlit, Flask, Hugging Face Spaces, TensorFlow Lite/ONNX, and Docker — with working code snippets, pinned requirements, free-tier capacity planning, and fixes for the failures that break demos: version-mismatch pickles, out-of-memory free tiers, and cold starts. Pick the option that matches your demo.

Written by Projectech21 min readPublished
For B.E./B.Tech AI/ML and CSE final-year students Topics: Python, Flask, Streamlit, TensorFlow Lite, ONNX, Docker
Illustration of a laptop displaying a deployed machine learning demo dashboard with prediction results, connected by lines to cloud server and edge device icons, in flat vector style.
Illustration generated for this guide.
In this guide

A trained model sitting in a Jupyter notebook earns you implementation marks. A model that an examiner can open in a browser, feed a fresh input, and watch produce an output earns you engineering marks — and it is the difference between a project that reads well on paper and one that survives the demo table. Deployment is simply the work of taking your .h5, .pkl, or .onnx file and putting a usable interface on it: a web page, an API, or an app running on a device.

This guide covers the five deployment paths that students actually use for machine learning projects, with working code for each, and the failures that break demos the night before the evaluation. If you are still choosing what to build, start with our machine learning project ideas for final-year students — every deployment pattern below maps to one or more of those projects.

The five deployment options, compared

Pick your deployment the way you would pick a model: by constraints, not by what is trending. The table below compares the options on the dimensions that matter for a student demo.

Option Typical response time Cost Setup effort Fits when…
Streamlit app ~1–3 s per prediction after a ~30–60 s cold start on the free tier Free (Streamlit Community Cloud) Low — one Python file Your demo is "upload an image / type text, see a result"
Flask REST API ~100–500 ms per request plus cold start Free tiers (Render, Railway) Medium You built or want a custom frontend, or a mobile app needs a backend
Gradio on Hugging Face Spaces Similar to Streamlit Free (CPU tier) Low You want a shareable public link with zero server configuration
TensorFlow Lite / ONNX on-device ~20–80 ms, no network needed Free Medium–high The demo must work offline, on a phone, or on a Raspberry Pi / edge board
Docker container Depends on the host machine Free Medium "It runs on my machine" is not an acceptable demo strategy

Two rules of thumb before you read further:

  • Match the option to the demo, not the resume. A plant disease classifier like the plant leaf disease detection CNN is a file-upload-and-predict demo — Streamlit or Gradio is the honest choice. A fake news detection tool that classmates might query from different frontends wants a Flask API.
  • Your training accuracy does not deploy with the model. The moment you quantize, change libraries, or resize inputs differently, you have a new artifact. Always re-verify predictions on the deployed artifact with a handful of known inputs before demo day.

How much model fits on a free tier

Students routinely train a model that their free hosting cannot load. Before choosing where to deploy, estimate your artifact's footprint: the model file on disk, plus the framework needed to run it, must fit in the host's RAM with headroom for a request in flight.

Artifact Typical file size RAM needed at inference Fits comfortably on
scikit-learn .pkl (TF-IDF + logistic regression, etc.) 1–50 MB ~2–4× file size Any free tier
Keras .h5 (MobileNet-size CNN) 15–100 MB ~3–5× file size once TensorFlow loads 1 GB+ instances; tight on 512 MB
Keras .h5 (large custom CNN / transformer) 200 MB–2 GB Often exceeds free tiers Paid tier or your own machine
Quantized .tflite (int8/fp16 MobileNet-class) ~4–17 MB Tens of MB with tflite-runtime Any free tier, phones, Raspberry Pi
ONNX (exported PyTorch model) Similar to the source checkpoint Depends on the runtime and ops used Anywhere ONNX Runtime installs

Host memory limits change, so verify current numbers on the provider's pricing page — but as a rule of thumb at the time of writing: Render's free tier gives you around 512 MB of RAM, Streamlit Community Cloud around 1 GB, and Hugging Face's free CPU Spaces around 16 GB. That ordering explains a common pattern: a .h5 that OOM-kills on Render deploys fine on a Space, and a quantized .tflite runs happily anywhere.

Two consequences worth internalizing:

  1. The framework is part of the footprint. Importing full TensorFlow can reserve several hundred megabytes before your model loads a single weight. If your model is small but the framework is heavy, serving via tflite-runtime or ONNX Runtime instead of full TensorFlow is often what makes a free tier viable.
  2. Measure, don't guess. Add a line to your startup log that prints the model file size, and watch the platform's memory metrics during your first real prediction. If the process gets killed only on the first request — not at startup — the killer is usually per-request allocations (large batch preprocessing, image decoding) stacked on top of an already-full heap. Reduce gunicorn workers to one first; that single change resolves most student OOM kills.

Option 1 — Streamlit: the fastest path to a working demo

Streamlit turns a Python script into a web app. For most classification and prediction projects this is the shortest route from notebook to a URL you can put in your report. Below is a complete, minimal app for an image classifier — the same pattern works for the plant leaf disease detection CNN or the brain tumor detection CNN:

import streamlit as st
import numpy as np
from PIL import Image
import tensorflow as tf

@st.cache_resource
def load_model():
    return tf.keras.models.load_model("plant_disease_model.h5")

model = load_model()
CLASS_NAMES = ["Healthy", "Early blight", "Late blight"]

st.title("Plant Leaf Disease Detection")
uploaded = st.file_uploader("Upload a leaf photo", type=["jpg", "jpeg", "png"])

if uploaded is not None:
    img = Image.open(uploaded).convert("RGB").resize((224, 224))
    arr = np.expand_dims(np.array(img) / 255.0, axis=0)
    preds = model.predict(arr)
    idx = int(np.argmax(preds))
    st.subheader(f"Prediction: {CLASS_NAMES[idx]}")
    st.write(f"Confidence: {float(preds[0][idx]) * 100:.1f}%")

Three details in that snippet do most of the reliability work:

  1. @st.cache_resource loads the model once instead of on every interaction. Without it, a 100 MB model reloads every time the user touches a widget, and your demo crawls.
  2. The preprocessing (resize((224, 224)), divide by 255) must exactly match training. This is the single most common silent bug in student deployments: the notebook normalized with a different mean, or resized to 256, and the deployed app confidently predicts garbage. Write the preprocessing once, in a function shared by training and serving, if you can.
  3. type=["jpg", "jpeg", "png"] on the uploader is not cosmetic — it is your first line of input validation (more on examiners and edge cases later).

Pin your requirements. Streamlit Community Cloud rebuilds your environment from requirements.txt on every deploy. Unpinned packages are the top cause of "it worked yesterday" failures:

streamlit==1.32.0
tensorflow==2.16.1
numpy==1.26.4
Pillow==10.3.0

Deploying to Streamlit Community Cloud, step by step:

  1. Push your project to GitHub (app file, requirements.txt, model file — see the Git LFS note in the failures section if your model exceeds ~100 MB). Our Git and GitHub guide for final-year projects covers repo setup.
  2. Go to share.streamlit.io, sign in with GitHub, and point it at your repo and app file.
  3. If your app needs API keys, add them under the app's Secrets — never commit keys to the repo.
  4. Expect a cold start of roughly 30–60 seconds on the free tier when the app has been idle; it then stays warm while in use. Free apps sleep after inactivity, so open the app yourself 5 minutes before your demo slot and click through once.

Streamlit is a demo interface, not an API. If your project needs other software to call it — a mobile app, a second service — skip to the Flask option instead of fighting Streamlit into something it is not.

Option 2 — Flask REST API: the classic web-backend route

When your project has a real frontend (a React page, an Android app) or when multiple clients need predictions, serve the model behind a REST endpoint. A text classifier such as fake news detection with NLP fits naturally here: the frontend sends text, the API returns a label and confidence.

from flask import Flask, request, jsonify
from PIL import Image
import numpy as np
import tensorflow as tf

app = Flask(__name__)
model = tf.keras.models.load_model("model.h5")

@app.route("/predict", methods=["POST"])
def predict():
    if "file" not in request.files:
        return jsonify({"error": "no file uploaded"}), 400
    img = Image.open(request.files["file"]).convert("RGB").resize((224, 224))
    arr = np.expand_dims(np.array(img) / 255.0, axis=0)
    preds = model.predict(arr)
    return jsonify({"class": int(np.argmax(preds)),
                    "confidence": float(np.max(preds))})

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"})

Notes that matter in practice:

  • Load the model once at startup (module level, as above), not inside the route. Loading inside the route multiplies latency by the model load time on every request.
  • The /health endpoint is how free hosting platforms check that your app is alive. Add it; platforms like Render use it for health checks, and it gives you a one-line way to verify the deploy worked (curl the health URL before you celebrate).
  • Return proper HTTP status codes. A 400 with {"error": "no file uploaded"} is what separates a student API from a toy. Examiners notice.
  • For scikit-learn pipelines, the serving code is even simpler — but the version trap is worse (see the failures table):
import joblib
model = joblib.load("fake_news_pipeline.joblib")
label = model.predict([request.json["text"]])[0]

Deploying a Flask API on Render (free tier), step by step:

  1. Add gunicorn to requirements and a Procfile containing web: gunicorn app:app.
  2. In the Render dashboard, create a Web Service from your GitHub repo. Set the start command to gunicorn app:app --workers 1 --timeout 120 --bind 0.0.0.0:$PORT.
  3. Bind to $PORT, not a hardcoded port. Render assigns the port via the PORT environment variable; hardcoding 5000 is the classic reason a Flask app "builds fine but never goes live."
  4. One worker on the free tier. Gunicorn's default sync workers each load a full copy of your model into memory. Two workers with a 200 MB model on a 512 MB instance is an out-of-memory kill waiting to happen. Start with --workers 1.

Testing the API locally before you deploy

Never debug your first deploy and your first request at the same time. Test the API on your own machine with curl first:

curl -X POST http://localhost:5000/predict -F "file=@leaf.jpg"
curl http://localhost:5000/health

A minimal frontend for your API

Your API does not need a React app to demo well. For the demo table, a single HTML page with a file input and a fetch() POST to /predict is enough — keep it in the same repo as the API. For automated testing (and for your report's testing section), a small Python client exercises the same endpoints without a browser:

import requests

# text endpoint
r = requests.post("http://localhost:5000/predict",
                  json={"text": "The central bank raised interest rates today."})
print(r.status_code, r.json())

# image endpoint
with open("leaf.jpg", "rb") as f:
    r = requests.post("http://localhost:5000/predict", files={"file": f})
print(r.status_code, r.json())

Two things to note: the frontend shows the API's error message instead of crashing when the request fails (examiners will test this), and during development you will hit CORS errors if the HTML file and the API run on different origins — add flask-cors while developing, then restrict allowed origins before anything public.

If your model is a TensorFlow .h5 of several hundred megabytes and the free tier keeps killing the process, consider serving with tflite-runtime and a converted .tflite file instead (Option 4) — the import alone is an order of magnitude lighter than full TensorFlow.

Option 3 — Hugging Face Spaces with Gradio: the zero-server demo link

Hugging Face Spaces gives you a public URL for a Gradio or Streamlit app without touching a server dashboard. For a project like the AI college enquiry chatbot, a Space is often the cleanest way to hand an examiner a link that just works.

import gradio as gr
import numpy as np
from PIL import Image
import tensorflow as tf

model = tf.keras.models.load_model("model.h5")
CLASS_NAMES = ["Healthy", "Early blight", "Late blight"]

def classify(img):
    img = img.convert("RGB").resize((224, 224))
    preds = model.predict(np.expand_dims(np.array(img) / 255.0, axis=0))
    return {name: float(p) for name, p in zip(CLASS_NAMES, preds[0])}

gr.Interface(fn=classify,
             inputs=gr.Image(type="pil"),
             outputs=gr.Label(num_top_classes=3),
             title="Plant Leaf Disease Detection").launch()

Deployment steps:

  1. Create a Hugging Face account and a new Space; choose the Gradio SDK.
  2. The Space is a git repo — clone it, add your app as app.py plus a requirements.txt, and push.
  3. Models larger than a few hundred megabytes should use Git LFS (Spaces support it) or be downloaded at startup from the Hugging Face Hub with huggingface_hub.snapshot_download — committing a 2 GB file directly will fail or stall.
  4. The free CPU tier sleeps after inactivity, like Streamlit's. For a demo, wake the Space beforehand. If your model needs a GPU at inference time (large transformers), the free tier will not cut it — that is a sign to quantize or to demo on your laptop instead.

Gradio's gr.Label output deserves a callout: it renders a confidence bar chart for the top classes, which reads far better in a demo than a raw JSON blob, and it costs you nothing extra.

Option 4 — On-device inference: TensorFlow Lite and ONNX

Some demos must work with no internet — a phone app, a Raspberry Pi in the lab, or a field device like the edge AI crop disease classifier, where the whole point is inference at the edge. That is what TensorFlow Lite and ONNX are for: they convert your trained model into a compact format that runs without the full training framework.

Converting a Keras model to TensorFlow Lite with quantization:

import tensorflow as tf

converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_types = [tf.float16]
tflite_model = converter.convert()

with open("model_fp16.tflite", "wb") as f:
    f.write(tflite_model)

What to know about the numbers:

  • A quantized MobileNet-class .tflite file is typically around 4–17 MB — small enough to bundle inside an Android APK or a Pi project without anyone noticing.
  • Full integer (int8) quantization shrinks the model further and speeds up inference on phones, but it needs a small representative dataset for calibration, and you should expect a small accuracy change versus the float model. Measure the converted model's accuracy separately and report that number — quoting your training accuracy next to a quantized artifact is misleading.
  • On a mid-range phone, image-classification inference with a quantized MobileNet typically lands in the tens of milliseconds — fast enough for real-time-feeling demos.

Exporting a PyTorch model to ONNX (useful when your inference target is a .NET app, a browser via ONNX Runtime Web, or anything outside the Python ecosystem):

import torch

torch.onnx.export(model, dummy_input, "model.onnx",
                  input_names=["input"], output_names=["output"],
                  dynamic_axes={"input": {0: "batch"}})

The dynamic_axes argument matters: without it, the exported model hardcodes the batch size you exported with, and the first time someone sends a different batch size it fails cryptically.

Serving a .tflite model from a lightweight API (the hybrid approach — edge-size model, server deployment):

pip install tflite-runtime
import numpy as np
import tflite_runtime.interpreter as tflite

interpreter = tflite.Interpreter(model_path="model_fp16.tflite")
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

def predict(arr):
    interpreter.set_tensor(input_details[0]["index"], arr.astype(np.float32))
    interpreter.invoke()
    return interpreter.get_tensor(output_details[0]["index"])

tflite-runtime is a fraction of the size of full TensorFlow, which solves two student problems at once: the 512 MB free-tier OOM kill, and the multi-minute cold start from importing TensorFlow.

Option 5 — Docker: making "it works on my machine" reproducible

If your examiner wants to run the project on their own laptop, or your team has three different laptops with three different Python versions, Docker removes the entire class of "but it worked on mine" failures. You are not required to deploy the container anywhere — even a container that only ever runs on your machine is worth it, because it freezes the environment.

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--timeout", "120", "app:app"]

Build and run:

docker build -t ml-demo .
docker run -p 8000:8000 ml-demo

Practical notes:

  • Order the Dockerfile as shown: dependencies install before your code is copied, so rebuilding after a code change reuses the cached dependency layer instead of reinstalling everything.
  • --no-cache-dir keeps the image smaller; on a slow college network the difference between a 1.2 GB and a 700 MB image is the difference between a smooth demo and an awkward wait.
  • Don't bake secrets into the image. Pass API keys with -e API_KEY=... at run time.
  • If your project is the brain tumor detection CNN, a Docker image plus a one-page README with the two commands above is a complete, defensible submission artifact — the examiner can reproduce your exact environment.

Keep your model artifact reproducible

A deployment is only as trustworthy as the artifact it serves. Two habits separate a defensible project from a fragile one:

  1. Version your model files. model.h5 tells nobody anything. plant_disease_mobilenet_v3_2026-09-20_fp16.tflite tells the examiner the architecture, the iteration, the date, and the quantization — and it tells you which file the deployed app should load six weeks later when you have five candidates in a folder. Keep a one-line MODEL_CARD.md next to it: what data it was trained on, the input size and normalization the serving code must use, and the accuracy you measured on the converted artifact (not the training log).
  2. Keep the training script in the same repo as the serving code. When the panel asks "how was this trained?" — and they will — pointing at train.py in the same repository, with the same pinned requirements-train.txt, is a complete answer. It also saves you when you need to re-export the model after fixing a preprocessing bug the night before the demo: you change one shared preprocessing function, retrain or reconvert, and redeploy, instead of hand-editing the serving code to match a notebook you can no longer reproduce.

Split your requirements accordingly: requirements.txt for serving (inference-only packages — this is where tflite-runtime replaces tensorflow), and requirements-train.txt for the heavier training environment. Your deployed image stays small, your training stays reproducible, and the two never contaminate each other.

Deployment failures every student hits (and the fix)

Almost every broken student deployment is one of the rows below. Read this table before demo week, not during it.

Error / symptom Cause Fix
InconsistentVersionWarning: Trying to unpickle estimator ... from version 1.3.0 when using version 1.5.0 The .pkl/.joblib was saved with a different scikit-learn version than the server has Pin the exact version in requirements.txt (e.g. scikit-learn==1.4.2) and re-export the model with that version
ModuleNotFoundError: No module named 'sklearn' at load time Some minimal Docker images and hosts lack it, or the name was mistyped Add scikit-learn (not sklearn) to requirements
Out of memory (used over 512Mi) / process killed on Render or Railway Model + framework exceed the free-tier RAM, often multiplied by gunicorn workers One worker (--workers 1), serve a quantized .tflite via tflite-runtime, or move to a host with more RAM
ImportError: libGL.so.1: cannot open shared object file opencv-python needs system GUI libraries absent on servers Use opencv-python-headless in requirements
Cold start takes minutes; first prediction times out Importing full tensorflow on a small instance Switch inference to tflite-runtime, or raise the server timeout (--timeout 120)
st.secrets raises SecretsFileNotFoundError Secrets configured locally but not added in the Streamlit Cloud dashboard Add them under the deployed app's Settings → Secrets; keep a secrets.toml example in the repo (without real values)
Git push rejected: file exceeds 100 MB Model committed directly to git Use Git LFS (git lfs track "*.h5"), or host the model on the Hugging Face Hub / cloud storage and download at startup
App builds but the platform says "no open ports" Hardcoded app.run(port=5000) while the platform assigns $PORT Bind to the environment port: read os.environ.get("PORT", 5000)
CORS errors when a separate frontend calls the Flask API Browser blocks cross-origin requests by default Add flask-cors and CORS(app) during development; restrict origins before any public deploy
Predictions are confidently wrong on the deployed app but fine in the notebook Preprocessing mismatch between training and serving (resize size, normalization, channel order) Extract preprocessing into one shared function used by both; verify the deployed app on known inputs

The preprocessing-mismatch row deserves emphasis because it is silent: nothing errors, the app looks finished, and every prediction is wrong. Before your demo, run five inputs with known labels through the deployed app — not the notebook — and confirm the outputs.

What the evaluation panel actually checks during the demo

Examiners rarely read your deployment architecture section closely. They probe it live, and they probe the same things every year:

  1. Cold start honesty. Open the app in front of them from a cold state at least once during practice. If the free tier sleeps and takes 60 seconds to wake, that is fine — as long as you know it and say so, instead of the panel watching a spinner while you panic. Wake the app before your slot.
  2. Wrong inputs. Someone will upload a photo of a cat to your leaf classifier, or paste a paragraph into your stock price prediction input. A file-type restriction and a try/except that returns a clean error message beats a full traceback on the projector every time.
  3. The pipeline question. "Walk me through what happens between upload and output." They want to hear: preprocessing → model → postprocessing, in your own words, with the actual input size and the reason for it. If you cannot explain why the image is resized to 224×224, the deployment looks borrowed.
  4. The trade-off question. "Why Streamlit and not Flask?" / "Why not run it on the device?" Have one honest sentence: the demo is a single-page upload-and-predict flow, so a full API added complexity without benefit — or the reverse, if you chose Flask. The decision table at the top of this guide is your preparation for this question.
  5. Live data vs. canned data. For something like the movie recommendation system, be ready to answer whether recommendations are computed live per user or precomputed. Either is acceptable; not knowing is not.
  6. The offline question. "What happens with no internet?" If your answer is "the demo needs internet," say it plainly and have the backup video ready (see the checklist). If you went the TFLite route, this is where that choice pays off visibly.

Pre-demo checklist

Run through this list 48 hours before the evaluation, not the night before:

  1. requirements.txt uses == pins for every package, and a fresh pip install -r requirements.txt in a clean virtualenv reproduces your environment.
  2. The model file is reachable by the deploy: Git LFS, Hub download, or bundled — and the deploy log shows it loading successfully.
  3. The app binds to the platform's assigned port and exposes a /health-style endpoint you have curled.
  4. Five known inputs produce correct outputs on the deployed URL, not just locally.
  5. An invalid input (wrong file type, empty text) produces a clean error message, not a traceback.
  6. Cold start measured and accounted for — app woken before your slot, or a warm-up request scripted.
  7. A 2-minute screen recording of the full demo exists as backup, in case the venue Wi-Fi fails.
  8. The repo README documents setup in under ten steps, including the Docker commands if you containerized.
  9. Secrets and API keys are in environment variables / the platform's secret store — none committed to git.
  10. You can explain the full request path — preprocessing, inference, postprocessing — without looking at notes.

Deployment is the part of a machine learning project that turns a good model into a finished project. Pick the simplest option that fits your demo, pin everything, verify the deployed artifact on real inputs, and keep a backup recording. Do that, and the demo table becomes the easiest part of your evaluation — which is exactly what it should be.

Browse the AI & Machine Learning project catalogue to find a project to deploy, and if you are still deciding what to build, the machine learning project ideas guide maps each idea to the deployment pattern that fits it.