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:
- 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-runtimeor ONNX Runtime instead of full TensorFlow is often what makes a free tier viable. - 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:
@st.cache_resourceloads 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.- 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. 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:
- 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.
- Go to share.streamlit.io, sign in with GitHub, and point it at your repo and app file.
- If your app needs API keys, add them under the app's Secrets — never commit keys to the repo.
- 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
/healthendpoint 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 (curlthe health URL before you celebrate). - Return proper HTTP status codes. A
400with{"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:
- Add
gunicornto requirements and aProcfilecontainingweb: gunicorn app:app. - 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. - Bind to
$PORT, not a hardcoded port. Render assigns the port via thePORTenvironment variable; hardcoding 5000 is the classic reason a Flask app "builds fine but never goes live." - 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
.h5of several hundred megabytes and the free tier keeps killing the process, consider serving withtflite-runtimeand a converted.tflitefile 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:
- Create a Hugging Face account and a new Space; choose the Gradio SDK.
- The Space is a git repo — clone it, add your app as
app.pyplus arequirements.txt, and push. - 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. - 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
.tflitefile 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-dirkeeps 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:
- Version your model files.
model.h5tells nobody anything.plant_disease_mobilenet_v3_2026-09-20_fp16.tflitetells 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-lineMODEL_CARD.mdnext 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). - 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.pyin the same repository, with the same pinnedrequirements-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:
- 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.
- 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/exceptthat returns a clean error message beats a full traceback on the projector every time. - 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.
- 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.
- 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.
- 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:
requirements.txtuses==pins for every package, and a freshpip install -r requirements.txtin a clean virtualenv reproduces your environment.- The model file is reachable by the deploy: Git LFS, Hub download, or bundled — and the deploy log shows it loading successfully.
- The app binds to the platform's assigned port and exposes a
/health-style endpoint you have curled. - Five known inputs produce correct outputs on the deployed URL, not just locally.
- An invalid input (wrong file type, empty text) produces a clean error message, not a traceback.
- Cold start measured and accounted for — app woken before your slot, or a warm-up request scripted.
- A 2-minute screen recording of the full demo exists as backup, in case the venue Wi-Fi fails.
- The repo README documents setup in under ten steps, including the Docker commands if you containerized.
- Secrets and API keys are in environment variables / the platform's secret store — none committed to git.
- 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.