MLOps for ML practitioners · Article 5

Docker in action: Dockerizing a real ML project

A full hands-on walkthrough: containerizing a heart disease prediction API, with every line of the Dockerfile explained in plain words.

Part of the MLOps series · read Article 4 first · next up: Flask for ML deployment

Article 4 covered what Docker is, how it works, and how to write a Dockerfile. Now we put it into practice. We'll take a small, working machine learning project, a heart disease prediction service, and containerize it start to finish.

By the end you'll have a real ML project packaged into a Docker image, a Dockerfile where you understand every single line, a running container serving predictions over an API, and the confidence to Dockerize your own projects.

We'll go slowly, because the goal here is understanding, not just copying commands.


Part 1The project we're containerizing

A small ML service that predicts whether a patient has heart disease from their health measurements, in two parts: a training script that trains a model and saves it, and a prediction API, using Flask, that loads the saved model and serves predictions over HTTP.

heart_disease_docker/
├── Dockerfile              ← the recipe (we'll write this)
├── .dockerignore           ← files to exclude from the image
├── requirements.txt        ← Python dependencies
├── train.py                ← trains and saves the model
├── app.py                  ← Flask API that serves predictions
├── data/
│   └── heart.csv           ← the dataset
└── models/
    └── model.pkl           ← the trained model (created by train.py)

Part 2Building the ML project, before Docker

Step 2.1 — the requirements file

requirements.txt lists the exact packages and versions the project needs:

scikit-learn==1.4.0
pandas==2.2.0
numpy==1.26.4
joblib==1.3.2
flask==3.0.0

Pinning exact versions with == matters for reproducibility: it guarantees the container installs the same versions every single time.

Step 2.2 — the training script

train.py loads the data, trains a Random Forest, and saves the model:

"""train.py — Train a heart disease classifier and save it."""
import pandas as pd
import joblib
import os
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

df = pd.read_csv("data/heart.csv")

X = df.drop(columns=["target"])
y = df["target"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

model = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
model.fit(X_train, y_train)

accuracy = accuracy_score(y_test, model.predict(X_test))
print(f"Model trained. Test accuracy: {accuracy:.4f}")

os.makedirs("models", exist_ok=True)
joblib.dump(model, "models/model.pkl")
print("Model saved to models/model.pkl")

Step 2.3 — the prediction API

app.py is a Flask server that loads the model and returns predictions:

"""app.py — Flask API serving heart disease predictions."""
from flask import Flask, request, jsonify
import joblib
import pandas as pd

app = Flask(__name__)

model = joblib.load("models/model.pkl")

FEATURES = ["age", "sex", "cp", "trestbps", "chol", "fbs", "restecg",
            "thalach", "exang", "oldpeak", "slope", "ca", "thal"]


@app.route("/health", methods=["GET"])
def health():
    """Simple health check to confirm the API is running."""
    return jsonify({"status": "healthy"})


@app.route("/predict", methods=["POST"])
def predict():
    """Accept patient data as JSON and return a prediction."""
    data = request.get_json()

    input_df = pd.DataFrame([[data[f] for f in FEATURES]], columns=FEATURES)

    prediction = int(model.predict(input_df)[0])
    probability = float(model.predict_proba(input_df)[0][1])

    return jsonify({
        "prediction": prediction,           # 1 = disease, 0 = no disease
        "probability": round(probability, 4),
        "interpretation": "Heart disease likely" if prediction == 1
                          else "Heart disease unlikely"
    })


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

host="0.0.0.0" is essential inside Docker. Use the default 127.0.0.1 instead and the app would only be reachable from inside the container, invisible from your laptop. More on this shortly.

Step 2.4 — the .dockerignore file

.dockerignore tells Docker what not to copy into the image, keeping it lean:

# Python cache
__pycache__/
*.pyc
*.pyo

# Virtual environments
.venv/
venv/

# Git
.git/
.gitignore

# Jupyter
.ipynb_checkpoints/

# Docs and misc
README.md
*.md

# We'll train inside the build, so raw data extras can be excluded if large
# (keep data/heart.csv since train.py needs it during the build)

Part 3Writing the Dockerfile, line by line

Here's the complete Dockerfile, followed by a detailed explanation of every line.

# ===== 1. Base image =====
FROM python:3.11-slim

# ===== 2. Environment variables =====
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

# ===== 3. Working directory =====
WORKDIR /app

# ===== 4. System dependencies =====
RUN apt-get update && \
    apt-get install -y --no-install-recommends libgomp1 && \
    rm -rf /var/lib/apt/lists/*

# ===== 5. Install Python dependencies (cached layer) =====
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ===== 6. Copy project files =====
COPY data/ ./data/
COPY train.py .
COPY app.py .

# ===== 7. Train the model during the build =====
RUN python train.py

# ===== 8. Expose the API port =====
EXPOSE 5000

# ===== 9. Start the API when the container runs =====
CMD ["python", "app.py"]

Every line, in plain words

1. FROM python:3.11-slim

In plain words "Start with a ready-made box that already has Python 3.11 installed on a minimal Linux system."

We don't build from scratch. An official Python image is the foundation. -slim means a stripped-down version, Python without a lot of extra tools we don't need, which keeps the final image small (smaller means faster to build, push, and deploy).

Analogy: instead of building a kitchen from raw materials, we rent an apartment that already has a stove and sink installed.

2. ENV PYTHONUNBUFFERED / PYTHONDONTWRITEBYTECODE

In plain words "Set two rules that make Python behave nicely inside a container."

PYTHONUNBUFFERED=1 forces Python to print output immediately instead of holding it in a buffer. Skip it, and your print() statements might not show up until the program ends, which makes debugging inside Docker painful. PYTHONDONTWRITEBYTECODE=1 stops Python from writing .pyc cache files we don't need in a container.

The \ at the end of the first line just means "continue on the next line," a way to set multiple variables in one instruction.

Analogy: posting two house rules on the fridge that every program inside follows.

3. WORKDIR /app

In plain words "Make a folder called /app inside the container, and work inside it from now on."

Everything after this runs inside /app. When we later copy train.py, it lands at /app/train.py. That keeps the project organized in one place instead of scattered around the container's filesystem.

Analogy: saying "we're working in the kitchen now," once, so you don't repeat the location for every instruction.

4. System dependencies

In plain words "Install a Linux system library that scikit-learn needs, then clean up the leftover install files."

Some ML libraries rely on system-level libraries that aren't part of Python itself. libgomp1 is one, handling parallel processing, installed through the Linux package manager apt-get. Breaking it down: apt-get update refreshes the list of available packages; apt-get install -y --no-install-recommends libgomp1 installs it, with -y auto-confirming and --no-install-recommends skipping optional extras; rm -rf /var/lib/apt/lists/* deletes the package list cache afterward to keep the image smaller. Chaining all three with && keeps them in one layer.

Analogy: a trip to the hardware store for one specific tool the recipe needs, then throwing away the shopping bags so the kitchen stays tidy.

5. Install Python dependencies (the caching trick)

In plain words "Copy just the requirements list, then install the packages it names."

COPY requirements.txt . copies only that one file into /app; RUN pip install --no-cache-dir -r requirements.txt installs everything it lists, with --no-cache-dir skipping pip's own download cache to keep the image smaller.

Why requirements first, and not all the code at once? This is the single most important habit in the whole Dockerfile. Docker caches each step. Code changes constantly; the dependency list rarely does. Install dependencies first, and tweaking app.py later reuses the cached dependency layer, rebuilding in seconds. Copy everything up front instead, and every tiny code change forces a full dependency reinstall, taking minutes.

Analogy: stock the pantry once, slowly, since it rarely changes. Then for each new dish you just grab fresh ingredients, fast. You don't restock the whole pantry to make a sandwich.

6. Copy project files

In plain words "Copy the dataset, the training script, and the API script into the container."

COPY data/ ./data/ brings in the data folder (with heart.csv) at /app/data/; COPY train.py . and COPY app.py . bring in the two scripts. All of this happens after installing dependencies, for exactly the caching reason above.

Analogy: the kitchen is set up and the pantry's stocked, so now bring in tonight's specific ingredients and the recipe card.

7. RUN python train.py

In plain words "Run the training script right now, while building the image, so the trained model gets baked in."

This executes train.py during the build. It trains the Random Forest and saves models/model.pkl inside the image itself. Anyone who runs a container from this image gets a model that's already there, ready to serve predictions instantly, no training needed at runtime.

Design note: training during the build works well for small, fast models like this one. For anything that takes hours to train, train separately, save the model (with DVC, as in Article 3), and COPY the pre-trained model in instead of training inside the build.

Analogy: pre-cooking the meal and sealing it into the kit, so the customer only has to reheat it.

8. EXPOSE 5000

In plain words "Announce that this container's app listens on port 5000."

The Flask API runs on port 5000, set in app.py. EXPOSE documents that. It's informational only, though: it doesn't actually open the port to your laptop. -p at docker run time does that (Part 4).

Analogy: a sign reading "service window at counter 5000." It tells people where to go, but you still need an actual door, the -p mapping, for anyone to get in.

9. CMD ["python", "app.py"]

In plain words "When someone starts a container from this image, run the API server automatically."

CMD is the default action. Starting the container launches app.py, which starts Flask and begins listening for prediction requests, the main job of this container.

Analogy: the default action when the meal kit is opened, heat and serve, unless you override it.


Part 4Building and running the container

Step 4.1 — build the image

From inside heart_disease_docker/:

docker build -t heart-disease-api:v1 .

docker build is the build command; -t heart-disease-api:v1 tags the image with a name and version; the trailing . is the build context, "find the Dockerfile here."

Docker executes each instruction and shows its progress:

[+] Building 45.2s
 => [1/8] FROM python:3.11-slim
 => [2/8] WORKDIR /app
 => [3/8] RUN apt-get update && apt-get install...
 => [4/8] COPY requirements.txt .
 => [5/8] RUN pip install --no-cache-dir -r requirements.txt
 => [6/8] COPY data/ ./data/
 => [7/8] COPY train.py . && COPY app.py .
 => [8/8] RUN python train.py
       Model trained. Test accuracy: 0.8525
       Model saved to models/model.pkl
 => exporting to image
 => => naming to docker.io/library/heart-disease-api:v1

The training output shows up mid-build. The model is baked into the image at this point.1

Step 4.2 — verify the image exists

docker images
REPOSITORY           TAG    IMAGE ID       CREATED         SIZE
heart-disease-api    v1     a3f5c2d8e1b9   2 minutes ago   450MB

Step 4.3 — run the container

docker run -d -p 8000:5000 --name heart-api heart-disease-api:v1

docker run creates and starts a container; -d runs it detached, in the background; -p 8000:5000 maps your laptop's port 8000 to the container's port 5000, the actual door that EXPOSE only advertised; --name heart-api gives it a friendly name; heart-disease-api:v1 is the image to run.

Your laptop curl localhost:8000 port 8000 Container (isolated world) Flask app listening on port 5000 -p 8000:5000 Without -p, port 5000 stays sealed inside the container, unreachable from the laptop
Fig. 1 — the -p flag is the actual door; EXPOSE was only ever the sign

Your Flask app listens on port 5000 inside the container, which is isolated from your laptop. -p 8000:5000 builds the bridge: requests to localhost:8000 on your laptop get forwarded to port 5000 in the container. Map to any host port you like; -p 9999:5000 would give you localhost:9999 instead.

Step 4.4 — confirm it's running

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
b1e2d4f6a8c0   heart-disease-api:v1   Up 10 seconds  0.0.0.0:8000->5000/tcp   heart-api

Part 5Testing the containerized API

Step 5.1 — health check

curl http://localhost:8000/health
{"status": "healthy"}

The API is alive and responding from inside the container.

Step 5.2 — make a prediction

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{
    "age": 63, "sex": 1, "cp": 3, "trestbps": 145, "chol": 233,
    "fbs": 1, "restecg": 0, "thalach": 150, "exang": 0,
    "oldpeak": 2.3, "slope": 0, "ca": 0, "thal": 1
  }'

Response

{
  "prediction": 1,
  "probability": 0.78,
  "interpretation": "Heart disease likely"
}

The model is now running inside a Docker container, serving predictions over HTTP.2 Ship this image to any machine with Docker installed and it runs exactly the same way.


Part 6Useful operations on a running container

View logs

docker logs heart-api
# the Flask startup output, and any request logs

Open a shell inside it, for debugging

docker exec -it heart-api /bin/bash
# now you're inside the container's Linux shell
# try: ls /app  → your files, including models/model.pkl
# type 'exit' to leave

Stop and remove

docker stop heart-api
docker rm heart-api
docker rmi heart-disease-api:v1   # the image too, if you want

Part 7Sharing your image

To let anyone else run your exact containerized model, push it to Docker Hub.

# 1. log in
docker login

# 2. tag with your Docker Hub username
docker tag heart-disease-api:v1 yourusername/heart-disease-api:v1

# 3. push
docker push yourusername/heart-disease-api:v1

Now anyone, anywhere, can run your model with two commands:

docker pull yourusername/heart-disease-api:v1
docker run -d -p 8000:5000 yourusername/heart-disease-api:v1

No "install Python," no "pip install," no "it works on my machine." It just runs. That's the entire point of Docker.


Part 8Handling data and models with volumes

This project baked the model into the image. Sometimes you'd rather data or models lived outside the container, so they persist and can be swapped without a rebuild. That's what volumes are for.

Baked in (this article) Image RUN python train.py — model.pkl inside Change the model? Rebuild the whole image. Mounted volume Laptop ./models/ -v Container /app/models Change the model? Just swap the file on disk.
Fig. 2 — baking a model in is simple; a volume lets you swap it without a rebuild

To mount a local models folder into the container instead of baking the model in:

docker run -d -p 8000:5000 \
  -v $(pwd)/models:/app/models \
  --name heart-api \
  heart-disease-api:v1

-v $(pwd)/models:/app/models connects your laptop's models folder to /app/models inside the container. Update the model file on your laptop, and the container sees the new version, no rebuild needed.

Analogy: instead of sealing the food inside the kit, you give the container access to your fridge, the volume, so it uses whatever's currently in there.

This connects directly to Article 3: version your data and models with DVC, dvc pull them onto the host, then mount them into the container as a volume. Git versions code, DVC versions data and models, Docker packages the runtime, and together they make a complete, reproducible ML workflow.


Summary

You've containerized a real ML project from scratch: built a working training script and prediction API, wrote a complete Dockerfile and understood every line of it, learned the layer-caching habit that matters most (requirements before code), built an image, ran a container, and mapped ports correctly, tested a live prediction API running inside Docker, and saw how to share images through Docker Hub and how volumes connect Docker back to Git and DVC for a full workflow.

ToolWhat it versions or does
GitYour code
GitHubCloud collaboration and sharing of code
DVCYour data and models
DockerYour entire runtime environment

Together, these guarantee your ML project runs identically everywhere, that every experiment is reproducible, and that you can hand your work to anyone, a teammate, a server, or your future self, and it will just work.

Worth exploring from here: Docker Compose, for running your model API alongside a database or frontend with one command; multi-stage builds, for shrinking production images further by separating build and runtime stages; GPU containers, using CUDA base images for deep learning; Kubernetes, for orchestrating and scaling many containers across a cluster; and CI/CD, for building and deploying your Docker image automatically on every push, which is exactly where this series goes next.

You've gone from raw code to a fully versioned, reproducible, containerized ML service. That's the foundation real-world MLOps is built on.