Docker for Machine Learning: Package a Model So It Runs Anywhere
Deployment
Docker
Containers
Containers explained visually: images vs containers, a production Dockerfile for a model API, layer caching you can play with, and how to keep images small.
“It works on my machine” is the oldest bug in software. PayGuard depends on a specific Python version, specific library versions and a model file. A container bundles all of that into one sealed box that runs the same on a laptop, a server or the cloud.
TipWhat you will learn
- How containers differ from virtual machines
- The path from a Dockerfile to a running container
- A production-ready Dockerfile for the PayGuard API
- How layer caching makes rebuilds fast, and how to keep images small
NoteThe Deployment series
Start: 1. Overview: from notebook to production
Build: 2. Tools · 3. MLOps · 4. Docker and containers · 5. Cloud and platforms
Operate: 6. Scale · 7. Monitoring · 8. Maintenance · 9. Updates and versioning
Containers vs virtual machines
A virtual machine carries a whole operating system. A container shares the host’s kernel and carries only the app and its libraries, so it starts in seconds and uses far less memory. Switch between the two views.
From Dockerfile to running container
Four words matter. A Dockerfile is the recipe. An image is the baked, read-only result. A registry stores images (Docker Hub, GitHub Container Registry, AWS ECR). A container is a running copy of an image.
flowchart TB A[Dockerfile<br/>recipe] -- docker build --> B[Image<br/>payguard:1.4.0] B -- docker push --> C[(Registry)] C -- docker pull --> D[Server or cloud] D -- docker run --> E[Container 1] D -- docker run --> F[Container 2]
A production Dockerfile for PayGuard
This uses a multi-stage build: the first stage installs libraries with build tools, and the final stage copies only what is needed, so build tools never reach production (Docker docs).
Dockerfile
# ---- stage 1: build wheels ----
FROM python:3.11-slim AS build
WORKDIR /w
COPY requirements.txt .
RUN pip wheel --no-cache-dir -r requirements.txt -w /wheels
# ---- stage 2: small runtime image ----
FROM python:3.11-slim
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
# rarely changes: model file
COPY models/payguard.joblib models/
# changes often: application code, copied last
COPY app.py .
# never run as root
RUN useradd -m appuser
USER appuser
EXPOSE 8000
HEALTHCHECK CMD python -c \
"import urllib.request as u; u.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]requirements.txt
fastapi==0.115.*
uvicorn==0.32.*
scikit-learn==1.5.*
pandas==2.2.*
joblib==1.4.*terminal
docker build -t payguard:1.4.0 .
docker run -d -p 8000:8000 --name payguard payguard:1.4.0
curl localhost:8000/health
ImportantPin the scikit-learn version
A model saved with one scikit-learn version may not load, or may behave differently, in another. The container pins the exact version used in training.
Layer caching: why order matters
Each instruction creates a layer. When you rebuild, Docker reuses every layer up to the first one that changed, then rebuilds everything after it. Pick what you changed and see what gets rebuilt.
Over a week of small code edits, the difference adds up. Here are 30 simulated builds in a busy week.
Show the code
import numpy as np, plotly.graph_objects as go
from ms_charts import COLORS, PLOTLY_CONFIG
rng = np.random.default_rng(8)
kind = rng.choice(["code", "model", "deps"], 30, p=[0.8, 0.13, 0.07])
good = np.where(kind == "code", 4, np.where(kind == "model", 7, 100)) + rng.normal(0, 1, 30)
bad = 100 + rng.normal(0, 4, 30)
fig = go.Figure()
fig.add_bar(x=np.arange(1, 31), y=bad, name="Bad order", marker_color=COLORS["rose"], opacity=0.55)
fig.add_bar(x=np.arange(1, 31), y=good, name="Good order", marker_color=COLORS["teal"])
fig.update_layout(barmode="overlay", height=360, xaxis_title="Commit", yaxis_title="Build time (s)", legend=dict(orientation="h", y=1.12))
fig.show(config=PLOTLY_CONFIG)Total for the week: 51 minutes with the bad order vs 4 minutes with the good one.
Keep images small
Smaller images download faster, start faster when autoscaling and have fewer packages that need security patches.
Show the code
parts = ["Base OS", "Python", "Build tools", "ML libraries", "Model", "Code"]
naive = [120, 50, 450, 380, 6, 0.1]
slim = [75, 50, 0, 300, 6, 0.1]
cols = [COLORS["slate"], COLORS["violet"], COLORS["rose"], COLORS["indigo"], COLORS["amber"], COLORS["teal"]]
fig = go.Figure()
for p, a, b, c in zip(parts, naive, slim, cols):
fig.add_bar(y=["Full base image", "Slim + multi-stage"], x=[a, b], name=p, orientation="h", marker_color=c)
fig.update_layout(barmode="stack", height=280, xaxis_title="Megabytes (illustrative)", legend=dict(orientation="h", y=1.25))
fig.show(config=PLOTLY_CONFIG)Also add a .dockerignore so data folders, notebooks and .git never enter the image:
.dockerignore
.git
.venv
data/
notebooks/
__pycache__/
*.ipynbRunning more than one container
Real services rarely run alone. Docker Compose starts PayGuard and a Redis cache together with one command.
compose.yaml
services:
api:
build: .
ports: ["8000:8000"]
environment:
REDIS_URL: redis://cache:6379
depends_on: [cache]
cache:
image: redis:7-alpineterminal
docker compose up -d # start both
docker compose logs -f api
docker compose down # stop and removeCommand cheat sheet
| Command | What it does |
|---|---|
docker build -t name:tag . |
Build an image from the Dockerfile in this folder |
docker images |
List local images and sizes |
docker run -d -p 8000:8000 name:tag |
Start a container in the background |
docker ps |
List running containers |
docker logs -f <id> |
Follow a container’s logs |
docker exec -it <id> sh |
Open a shell inside a running container |
docker push registry/name:tag |
Upload an image to a registry |
Common pitfalls
| Pitfall | Fix |
|---|---|
Using the latest tag in production |
Tag with a version (1.4.0) or the git commit |
| Secrets baked into the image | Pass them as environment variables or secret mounts at run time |
| Running as root | Add a user and USER appuser |
| Huge images from copying data | Use .dockerignore, mount data instead |
Summary
- A container packs the app, libraries and model so it runs the same everywhere.
- Dockerfile → image → registry → container is the whole workflow.
- Copy requirements first and code last to keep layer caching effective.
- Use slim bases, multi-stage builds, pinned versions and a non-root user.
- Next: choose where those containers run with cloud platforms.