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
Companion notebook: lesson-07-docker.ipynb — worked examples and self-checking exercises.
The problemYour laptop is not the server
| 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.
VocabularyImages, containers, layers
docker buildrating-api:1.0.0docker run- 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
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", ...]
| 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 |
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
ENVandARGvalues 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:
.dockerignorelists.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.
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 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.
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
- Services find each other by name: the portal calls
http://api:8000 - Secrets appear as files in
/run/secrets, whereSettingsreads 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 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.
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
- Move every setting into
Settings; nothing configurable left in code - A
.dockerignorethat keeps secrets, data, and caches out - A multi-stage Dockerfile with
apiandportaltargets; non-root; health check - Measure: image sizes; rebuild time after a one-line change
compose.yaml: PostgreSQL, the init job, the API, the portal, secrets as filesdocker compose up; quote with curl; open the portal- Prove no secret is in either image (
docker inspect,docker history)
Answer key
| 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 |
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
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.