Siddharth, 16, from Mumbai had built a sentiment analysis API using FastAPI and a fine-tuned DistilBERT model. It worked perfectly on his laptop โ Python 3.10, transformers 4.36, a specific PyTorch version, and a dozen other libraries he'd installed over months.
When he deployed it to a friend's server โ a different Ubuntu version, different Python โ nothing worked. "ModuleNotFoundError." "CUDA version mismatch." "NumPy incompatible with scikit-learn." Two hours of debugging later, he had learned the most important lesson in software engineering: environment is everything.
"Docker solves this," his mentor said. "You package the code and everything it needs into a container. Then it runs identically everywhere โ your laptop, a cloud VM, or a Kubernetes cluster." Siddharth dockerised his API in 20 minutes. It's been running in production ever since.
A trained ML model is not just a .pkl file. It's a system: a specific Python version, library versions, system libraries, CUDA drivers, and model weights. Docker captures all of this in an image โ a layered, immutable snapshot of the entire environment.
- Image: Blueprint (like a class in Python). Read-only. Built from a Dockerfile.
- Container: Running instance of an image (like an object). Isolated process.
- Registry: Storage for images. Docker Hub, GitHub Container Registry, AWS ECR.
- Layer caching: Each Dockerfile instruction creates a layer. Unchanged layers are cached โ rebuilds are fast.
A Dockerfile is a recipe that builds your image layer by layer. Each instruction creates one layer. Order matters โ put things that change rarely (Python install, pip dependencies) before things that change often (your code).
# โโ Dockerfile โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# 1. Base image โ use official Python slim (smallest that works)
FROM python:3.11-slim
# 2. Set working directory inside container
WORKDIR /app
# 3. Install system dependencies (kept separate for layer caching)
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# 4. Copy requirements FIRST (changes rarely โ cached until requirements change)
COPY requirements.txt .
# 5. Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# 6. Copy application code (changes frequently โ placed AFTER pip install)
COPY app/ ./app/
COPY model/ ./model/
# 7. Expose the port the app listens on
EXPOSE 8000
# 8. Set environment variables
ENV PYTHONUNBUFFERED=1
ENV MODEL_PATH=/app/model/distilbert-sentiment
# 9. Run the FastAPI app with uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# โโ .dockerignore โ do NOT copy these into the image โโโโโโโโโโโโโ
__pycache__/
*.py[cod]
.git/
.github/
*.ipynb
*.ipynb_checkpoints/
venv/
.env
.env.*
tests/
*.log
# Never commit secrets โ use Docker secrets or env vars at runtime
| Command | What it does |
|---|---|
| docker build -t sentiment-api . | Build image named "sentiment-api" from Dockerfile in current dir |
| docker run -p 8000:8000 sentiment-api | Run container, map host port 8000 โ container port 8000 |
| docker run -d --name my-api sentiment-api | Run detached (background) with a name |
| docker logs my-api | View container stdout/stderr |
| docker exec -it my-api bash | Open shell inside running container |
| docker stop my-api && docker rm my-api | Stop and delete container |
| docker images | List all local images |
| docker push yourdockerhub/sentiment-api:v1 | Push image to Docker Hub registry |
Real ML APIs often need more than one service โ the ML API, a Redis cache for storing predictions, maybe a monitoring sidecar. docker-compose orchestrates multiple containers with a single docker compose up command.
# โโ docker-compose.yml โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
version: "3.9"
services:
api:
build: . # build from local Dockerfile
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://cache:6379 # use service name "cache" as hostname
- MODEL_PATH=/app/model/distilbert-sentiment
depends_on:
- cache # wait for Redis before starting
volumes:
- ./model:/app/model:ro # mount model weights read-only (no copy in image)
restart: unless-stopped
cache:
image: redis:7-alpine # official Redis image, no custom Dockerfile needed
ports:
- "6379:6379"
volumes:
- redis_data:/data # persist Redis data across restarts
restart: unless-stopped
volumes:
redis_data: # named volume managed by Docker
# โโ app/predict.py โ Redis caching for predictions โโโโโโโโโโโโโโโ
import redis, json, hashlib
from transformers import pipeline
model = pipeline("sentiment-analysis",
model="./model/distilbert-sentiment")
r = redis.Redis.from_url("redis://cache:6379", decode_responses=True)
def predict_with_cache(text: str) -> dict:
# Hash the input text as cache key
key = "pred:" + hashlib.sha256(text.encode()).hexdigest()
# Return cached result if available
cached = r.get(key)
if cached:
return json.loads(cached)
# Run inference and cache for 1 hour (3600 seconds)
result = model(text)[0]
r.setex(key, 3600, json.dumps(result))
return result
Continuous Integration (CI) automatically runs tests and builds on every push to GitHub. This catches regressions before they reach production. Here's a complete pipeline that tests your API, builds the Docker image, and pushes to Docker Hub.
# โโ .github/workflows/ci.yml โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
name: ML API CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ github.repository }} # yourusername/sentiment-api
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt pytest httpx
- name: Run tests
run: pytest tests/ -v --tb=short
build-and-push:
needs: test # only runs if test job passes
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # only on main branch
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} # stored as GitHub secret
- name: Extract Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha- # git SHA for traceability
type=raw,value=latest # "latest" tag on main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions build cache
cache-to: type=gha,mode=max # fast rebuilds