Dockerising the service
A container image is one bundle holding the service, its Python and its pinned packages, built from a Dockerfile so the same image runs on your laptop, on a colleague's machine and on a cloud host without anything being installed there first.
Last updated: 19 Oct, 2026 · MLOps
The Request and response schemas lesson finished the service: it starts, it checks what it is sent and it predicts. It runs on this machine because this machine has the right Python and the right versions of pandas and scikit-learn. Somewhere else it may not, and the Why and what is Docker video names the failure exactly: the application that works in the developer's environment fails in the QA environment, because the operating system, the hardware and the library versions differ.
The video's answer is the moving-house container: put everything in one box, move the box, unpack it, and nothing is missing. Its two words for what that buys are environment standardization and build once, deploy anywhere, and both describe a container image.
The clip's Dockerfile builds a Flask app on python:3.9-slim; this one builds the FastAPI service on python:3.12-slim because Python 3.9 reached end of life in October 2025, and FROM, WORKDIR, COPY, RUN, EXPOSE and CMD are unchanged.
Writing the Dockerfile
A Dockerfile is a list of instructions, each one a step Docker runs in order on top of the result of the last. The first three set the base and install the dependencies, and the order is deliberate: the pin list is copied on its own so the install can be reused when only the source changes.
FROM python:3.12-slim
WORKDIR /app
# copy requirements first so the layer caches while source changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txtCopying the project in
Next come the parts of the project the service reads: the package the pipeline lives in, the stage config, the two YAML files and app.py, and the artifacts folder holding the fitted preprocessor and the trained model. The image carries the model, which is why the container can predict without a model file on the host.
COPY src/ ./src/
COPY config/ ./config/
COPY params.yaml schema.yaml app.py ./
COPY artifacts/ ./artifacts/Setting the import path, the port and the command
The last three lines say how the image behaves when it is run. PYTHONPATH points at the package folder so import sp works inside the container, EXPOSE records the port the service listens on, and CMD is the command that starts when a container is created from this image: uvicorn, the server that runs a FastAPI app, told to serve app from app.py. Binding to 0.0.0.0 rather than localhost is what lets traffic from outside the container reach it.
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]Keeping local files out of the build
Docker sends the whole folder you point it at to the build. A .dockerignore keeps the parts that have no business in an image out of it: compiled caches, local logs, a virtual environment, the git history and the MLflow store. The two cache lines need **/ in front of them, because a pattern is matched against the whole relative path and is not applied at every level the way .gitignore would.
# patterns match the whole relative path and are not recursive,
# so a bare __pycache__/ would only catch one at the top level
**/__pycache__/
**/*.pyc
logs/
.venv/
.git/
mlruns/Building the image
The build runs from the project root, the folder that holds the Dockerfile, because the dot at the end of the build command is that folder and every COPY path is read inside it.
# the build context is the project root, the folder that holds the Dockerfile
cd student-performanceWith the context set, one command reads the file and produces a named image. The tag has two halves, a name and a version, and both are yours to choose. The build log prints each instruction as a numbered step, and the listing underneath sorts those steps back into file order so the eight lines of the Dockerfile and the eight steps of the build line up.
# build the image, and list the eight steps Docker ran, in Dockerfile order
docker build -t student-performance:v1 . 2>&1 \
| sed -n 's/^#[0-9]* \(\[[0-9]\/8\]\)/\1/p' \
| sed 's/@sha256:[0-9a-f]*//' | sort
# the tag now names a real image on this machine
docker images student-performance:v1 --format '{{.Repository}}:{{.Tag}} {{.Size}}'[1/8] FROM docker.io/library/python:3.12-slim [2/8] WORKDIR /app [3/8] COPY requirements.txt . [4/8] RUN pip install --no-cache-dir -r requirements.txt [5/8] COPY src/ ./src/ [6/8] COPY config/ ./config/ [7/8] COPY params.yaml schema.yaml app.py ./ [8/8] COPY artifacts/ ./artifacts/ student-performance:v1 734MB
Starting the container without a port mapping
A container has its own network. The service inside it listens on port 8000, but that port belongs to the container, not to the host, and nothing on this machine can reach it until the two are joined. Running the image with no mapping and then calling it is the quickest way to see what that means.
# start the container with no -p, so nothing on the host reaches port 8000
docker run -d --name sp-noport student-performance:v1 > /dev/null
sleep 3
curl -sS --max-time 3 http://localhost:8077/health
# remove it before trying again, so the name is free
docker rm -f sp-noport > /dev/nullcurl: (7) Failed to connect to localhost port 8077 after 0 ms: Couldn't connect to server
The service is running the whole time. The call fails because there is no route from a port on this machine into the container, which is what -p creates.
Mapping the port and calling the service
The Docker basics video spells the mapping out: in 8077:8000 the first number is the host port and the second is the container port, and the first is the one you call. The body for the POST goes in a file so curl can send it with -d @.
{
"gender": "female",
"race_ethnicity": "group B",
"parental_level_of_education": "bachelor's degree",
"lunch": "standard",
"test_preparation_course": "none",
"reading_score": 72,
"writing_score": 74
}# -d runs it in the background, -p joins host port 8077 to container port 8000
docker run -d --name sp-test -p 8077:8000 student-performance:v1 > /dev/null
sleep 3
# the same two calls as before, now against the service inside the container
curl -s http://localhost:8077/health; echo
curl -s -X POST http://localhost:8077/predict \
-H 'Content-Type: application/json' -d @record.json; echo
# stop and delete the container, so the name and the port are free again
docker rm -f sp-test > /dev/null{"status":"ok"}
{"math_score":69.34}What the containerised service proves
- The same score, 69.34, comes back from the container that the prediction pipeline returns on this machine, so the image carries the same preprocessor, the same model and the same code path.
- Nothing was installed to make that happen. The host needs Docker and no more: no Python version, no pandas, no scikit-learn.
- The port is the only thing joining the two worlds. Change 8077 and you call a different door; change 8000 and you have to change the command that starts the service.
- The container is disposable. It was created, used and deleted, while the image it came from is still there to create the next one.
A virtual machine against a container
| A virtual machine | A container | |
|---|---|---|
| Carries | A whole guest operating system | Your code, Python and the packages |
| Resources | Allocated up front to that machine | Shared, and given back when idle |
| Starting it | Boots like a computer | Starts like a process |
| Size on disk | Gigabytes of operating system | A base image plus your layers |
| What it standardises | The machine | The environment the service runs in |
Where an image is the unit you ship
- Handing the service to another team, where they run one command and get the version you tested rather than a setup guide.
- Deploying to a cloud host, where the machine pulls an image and runs it, and never holds your source.
- Pinning a release, where
v1andv2are two images on disk and going back is running the older tag.
artifacts/, so a build run before the training pipeline has written a model produces an image whose service fails on its first prediction. Train first, check the two joblib files are there, then build. For the same reason, never copy a credentials file or a .env into an image: anyone who can pull it can read what is inside.Related
- Previous: Request and response schemas
- Next: Smaller images with layer caching
- The service being packaged: Serving the model with FastAPI
- Reference: The Dockerfile reference
- Run the image on a different host port, such as
-p 9000:8000, and call the new port. - Delete
artifacts/, build again, run the container and read the error the first prediction gives. - Add
record.jsonto.dockerignore, rebuild, and confirm the service still answers.
Little by little, you're building something great.