“It works on my machine”, solved: multi-stage images, configuration from the environment, secrets that never enter an image, and the whole platform started with one command.
Companion notebook: lesson-07-docker.ipynb — worked examples and self-checking exercises.
| On your laptop | On the server |
|---|---|
| Python 3.14 | Python 3.10, maybe |
| scikit-learn 1.9.1 | whatever pip found that day |
| macOS or Windows libraries | Linux libraries |
A .env file you forgot about | no such file |
The model in ~/Downloads | … |
A container image packages the operating-system libraries, the Python interpreter, the locked dependencies, your code, and the model into one artifact that runs the same way everywhere.
A model that loads under scikit-learn 1.9 may not load under 2.0 (Course 3, Lesson 8A). An image freezes the whole stack the model was validated on.
docker buildrating-api:1.0.0docker runSmall, cacheable, non-root, secret-free.
# ---- stage 1: build the virtualenv, with uv
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim \
AS build-api
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
# dependencies only: cached until uv.lock changes
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-dev --no-install-project \
--extra postgres
# the project: a code change rebuilds only this
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
RUN uv sync --locked --no-dev --no-editable \
--reinstall-package rating-platform --extra postgres
# ---- stage 2: the runtime image, no build tools
FROM python:3.12-slim-bookworm AS api
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=build-api --chown=app:app \
/app/.venv /app/.venv
COPY --chown=app:app models ./models
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
RP_MODEL_DIR=/app/models/champion
USER app
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request as u; \
u.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "rating_platform.api:app",
"--host", "0.0.0.0", "--port", "8000"]
Dependencies before code, so editing api.py doesn’t reinstall pandas. Only the finished virtualenv crosses to stage 2. The model is baked in: image tag + model version = exactly what served a quote. USER app: nothing in the container runs as root. HEALTHCHECK: Docker knows when it is ready, and when it is sick.
We rebuilt the portal image after changing cli.py, restarted it, and the change wasn’t there. The Docker layers had rebuilt correctly, but inside the build uv reused its cached wheel of our own project: uv decides whether a local project changed by looking at pyproject.toml, not at the files under src/.
$ docker compose exec portal python -c \
"import rating_platform.cli, inspect; \
print('toolbarMode' in inspect.getsource(rating_platform.cli))"
False
Stale code in a fresh image is one of the most confusing failures there is: the build log looks perfect. --reinstall-package rating-platform forces uv to rebuild our own package every time; it is cheap because dependencies are still cached.
After every deployment, ask the running system what it is: the API’s /health returns the model version; the image tag names the code version.
# naive
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY . .
RUN pip install uv && \
uv pip install --system ".[postgres,portal]"
ENV RP_API_KEY=prod-key-4f9a
CMD ["uvicorn", "rating_platform.api:app", ...]
| Naive | Multi-stage | |
|---|---|---|
| Rebuild after a one-line code change | 20.2 s | 5.6 s |
| Size | 784 MB, everything | API 521 MB, portal 807 MB |
| Runs as | root | app (uid 10001) |
| API key | in the image | mounted at run time |
Contents of /app | Dockerfiles, compose file, source | the venv and the model |
| Health check | none | /health |
$ docker inspect -f '{{json .Config.Env}}' l07-naive
["PATH=...", "PYTHON_VERSION=3.12.14", ...,
"RP_API_KEY=prod-key-4f9a"]
$ docker history --no-trunc l07-naive | grep KEY
ENV RP_API_KEY=prod-key-4f9a
ENV and ARG values are stored in the image metadata and history, forever.dockerignore lists .env, secrets, *.db, .git, .venvA key that was ever in an image, or ever committed to Git, is compromised. Rotate it; don’t just delete it.
Same image, any environment.
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="RP_", env_file=".env",
secrets_dir="/run/secrets") # when it exists
database_url: str = "sqlite:///rating_platform.db"
database_password: SecretStr | None = None
model_dir: Path = Path("models/champion")
liability_basis: Literal["manual", "model"] = "model"
api_key: SecretStr = SecretStr("")
api_url: str = ""
target_loss_ratio: float = 0.68
def sqlalchemy_url(self) -> URL:
url = make_url(self.database_url)
if self.database_password is not None:
url = url.set(password=self.database_password
.get_secret_value())
return url
| Source (highest wins) | Example |
|---|---|
| Environment variable | RP_LIABILITY_BASIS=manual |
.env file (development) | RP_API_KEY=dev |
| Secret file | /run/secrets/rp_api_key |
| Default in code | SQLite, model basis |
The URL says where the database is (configuration); the password is a secret, kept separately. A rendered URL masks it: postgresql+psycopg://rating:***@db:5432/rating.
services:
db:
image: postgres:17-alpine
environment: {POSTGRES_PASSWORD_FILE: /run/secrets/db_password}
secrets: [db_password]
volumes: [pgdata:/var/lib/postgresql/data]
init: # one-off: tables + the 2025 book, then exit
image: rating-api:1.0.0
command: [rating-platform, init-db, --book, /data/book_2025.csv]
depends_on: {db: {condition: service_healthy}}
api:
build: {context: ., target: api}
environment: {RP_DATABASE_URL: "postgresql+psycopg://rating@db/rating"}
secrets: [{source: api_key, target: rp_api_key}, ...]
ports: ["8000:8000"]
depends_on: {init: {condition: service_completed_successfully}}
portal:
build: {context: ., target: portal}
ports: ["8501:8501"]
depends_on: {api: {condition: service_healthy}}
secrets:
api_key: {file: ./secrets/api_key.txt} # and db_password
http://api:8000/run/secrets, where Settings reads them; target: names them to match the settings field$ docker compose up --build -d
Container rating_platform-db-1 Healthy
Container rating_platform-init-1 Exited
Container rating_platform-api-1 Healthy
Container rating_platform-portal-1 Started
$ docker compose logs init
loaded 18,249 vehicles into postgresql
(expected losses from glm-dcbe5b5dbb)
$ curl -s localhost:8000/health
{"status":"ok","model_version":"glm-dcbe5b5dbb",
"database":"postgresql"}
$ docker compose exec api id
uid=10001(app) gid=10001(app) groups=10001(app)
$ docker compose exec api ls /run/secrets
rp_api_key rp_database_password
| Everyday commands | |
|---|---|
docker compose ps | what’s running, and healthy? |
docker compose logs -f api | follow a service’s output |
docker compose exec api sh | a shell inside it |
docker compose up -d --build api | rebuild and replace one service |
docker compose down | stop (add -v to delete the data) |
A fresh API container answers its first health check 2.2 s after docker run, including loading and verifying the model.
These are the properties that managed platforms (AWS App Runner or ECS, Azure Container Apps, Google Cloud Run, Kubernetes) expect. Deploying there is pushing the image to a registry and describing the same services, variables, and secrets in the platform’s own format.
A managed database (not a container) is the usual choice in production: backups, replication, and upgrades become someone else’s job.
Settings; nothing configurable left in code.dockerignore that keeps secrets, data, and caches outapi and portal targets; non-root; health checkcompose.yaml: PostgreSQL, the init job, the API, the portal, secrets as filesdocker compose up; quote with curl; open the portaldocker inspect, docker history)| API image | 521 MB |
| Portal image | 807 MB |
| Rebuild after code change | ≈6 s (naive ≈20 s) |
| User in the container | app, uid 10001 |
| Rows loaded by init | 18,249 |
Model artifacts that prove what they are, promotion rules, a CI/CD pipeline that builds and smoke-tests images, and drift monitoring on the quotes coming in.