Course 4 · Lesson 7 Containerization with Docker
Python for Actuarial Students · Course 4
Lesson 7

Containerization with Docker

“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.

Lesson 7 · OverviewBy the end of this lesson you can…

  • Explain images, containers, layers, and registries
  • Write a multi-stage Dockerfile for a uv project, ordered for fast rebuilds
  • Keep images small, non-root, and free of secrets and clutter
  • Configure an application entirely from the environment
  • Pass secrets as files, and keep the database password out of URLs
  • Run a database, a one-off job, an API, and a portal together with Docker Compose
75 min
Concepts & worked examples
75 min
Lab: containerize the platform

Companion notebook: lesson-07-docker.ipynb — worked examples and self-checking exercises.

The problemYour laptop is not the server

On your laptopOn the server
Python 3.14Python 3.10, maybe
scikit-learn 1.9.1whatever pip found that day
macOS or Windows librariesLinux libraries
A .env file you forgot aboutno 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.

VocabularyImages, containers, layers

Dockerfilethe recipe
→ docker build
Imageread-only layers, tagged rating-api:1.0.0
→ docker run
Containera running instance
  • Each Dockerfile instruction adds a layer. Layers are cached: if an instruction and everything before it are unchanged, Docker reuses the layer
  • One image, many containers: the API can run 1 or 20 copies
  • A registry (Docker Hub, GitHub, your company’s) stores images, as Git stores code
  • Containers are disposable: anything they must keep (the database) lives in a volume
Part 1

A Good Dockerfile

Small, cacheable, non-root, secret-free.

Multi-stageBuild with tools; ship without them

# ---- 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.

CachingA trap we fell into

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.

MeasuredA naive Dockerfile versus ours

# 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", ...]
NaiveMulti-stage
Rebuild after a one-line code change20.2 s5.6 s
Size784 MB, everythingAPI 521 MB, portal 807 MB
Runs asrootapp (uid 10001)
API keyin the imagemounted at run time
Contents of /appDockerfiles, compose file, sourcethe venv and the model
Health checknone/health

SecretsAnything in an image is public to its users

$ 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
  • Deleting the file in a later layer doesn’t help: the earlier layer still contains it
  • Keep secrets out of the build context entirely: .dockerignore lists .env, secrets, *.db, .git, .venv

A key that was ever in an image, or ever committed to Git, is compromised. Rotate it; don’t just delete it.

Part 2

Configuration & Secrets

Same image, any environment.

settings.pyEverything configurable comes from the 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 variableRP_LIABILITY_BASIS=manual
.env file (development)RP_API_KEY=dev
Secret file/run/secrets/rp_api_key
Default in codeSQLite, 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.

ComposeThe platform in one file

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
dbhealthy
→
initexits 0
→
apihealthy
→
portal
  • Services find each other by name: the portal calls http://api:8000
  • Secrets appear as files in /run/secrets, where Settings reads them; target: names them to match the settings field
  • Only the API and portal publish ports; the database is reachable only inside the network

Running itOne command

$ 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 pswhat’s running, and healthy?
docker compose logs -f apifollow a service’s output
docker compose exec api sha shell inside it
docker compose up -d --build apirebuild and replace one service
docker compose downstop (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.

Cloud-readyWhat the image now makes possible

  • Stateless: the API keeps nothing on its own disk; quotes go to the database. Any number of copies can run behind a load balancer
  • Configured by environment: the same image runs in test and production with different variables and secrets
  • Observable: logs to standard output, a health endpoint, the model version on every quote
  • Disposable: start in seconds, stop cleanly; replace rather than patch

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.

Actuarial Lab · 75 minContainerize the platform

  1. Move every setting into Settings; nothing configurable left in code
  2. A .dockerignore that keeps secrets, data, and caches out
  3. A multi-stage Dockerfile with api and portal targets; non-root; health check
  4. Measure: image sizes; rebuild time after a one-line change
  5. compose.yaml: PostgreSQL, the init job, the API, the portal, secrets as files
  6. docker compose up; quote with curl; open the portal
  7. Prove no secret is in either image (docker inspect, docker history)

Answer key

API image521 MB
Portal image807 MB
Rebuild after code change≈6 s (naive ≈20 s)
User in the containerapp, uid 10001
Rows loaded by init18,249

RecapLesson 7 checklist

  • My images are multi-stage, dependencies before code
  • Each service gets only the packages it needs
  • Containers run as a non-root user with a health check
  • I check that the running container has the code I built
  • All configuration comes from the environment
  • Secrets are mounted files, never in images, URLs, or Git
  • Compose starts services in dependency order
  • Data lives in volumes (or a managed database), not containers
Up next
Lesson 8 · Part A

Integration, Delivery & Monitoring

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.