Project — Packaging & Reproducibility
Build a reproducible, lean Docker image that runs the Adult / Census Income model
(predict whether a person earns >50K) as a service and composes with a real dependency. This
is real Docker and shell work on your own machine — you write a Dockerfile, you run
docker build and docker run, and the image is the proof. Nothing here runs in a browser.
| Dataset | Adult / Census Income — the lessons containerize the Lending Club scorer; the project applies the same discipline yourself on Adult / Census |
| Start from | Fork dutchengineer-org/phase2-starter — it hands you train.py and a working package (run python train.py to produce the model artifact), so you containerize a model that already works. This fork is the repo you carry through the ship modules; M6 through M9 build in it too |
| Aim for | The reference package — the final shape, with the serializable TrainedModel from M4. This module makes no version bump: it changes nothing about the package’s public API, so there is nothing to increment (the semver rules only move the version when the API gains or breaks something). From here every Phase 2 module builds around this shape rather than growing it. Read it to see what good looks like; do not fork it |
| Done when | It passes every line of the rubric below, then you push to GitHub |
Build a reproducible, lean Docker image that runs the Adult / Census Income model
(predict whether a person earns >50K) as a service and composes with a real dependency. This
is real Docker and shell work on your own machine — you write a Dockerfile, you run
docker build and docker run, and the image is the proof. Nothing here runs in a browser.
| Dataset | Adult / Census Income — the lessons containerize the Lending Club scorer; the project applies the same discipline yourself on Adult / Census |
| Start from | Fork dutchengineer-org/phase2-starter — it hands you train.py and a working package (run python train.py to produce the model artifact), so you containerize a model that already works. This fork is the repo you carry through the ship modules; M6 through M9 build in it too |
| Aim for | The reference package — the final shape, with the serializable TrainedModel from M4. This module makes no version bump: it changes nothing about the package’s public API, so there is nothing to increment (the semver rules only move the version when the API gains or breaks something). From here every Phase 2 module builds around this shape rather than growing it. Read it to see what good looks like; do not fork it |
| Done when | It passes every line of the rubric below, then you push to GitHub |
Start here
- Fork the starter →
dutchengineer-org/phase2-starter, clone it, runpython train.pyto produce the model artifact - Work the tasks below in order — each maps to a lesson you just finished
- Check yourself against the rubric, then push and submit
By the end you have an image that builds from scratch on a clean machine, pins every
dependency, takes its config and secrets from the environment (not baked into layers), and is
small enough to justify every megabyte — then runs as a long-lived, port-published service. A
single docker compose up stands it up alongside a Postgres dependency, reached by service
name and gated on readiness. Another engineer should be able to clone the repo, run one
docker build then one docker run to score a record, and one docker compose up to bring
up the whole stack, on a machine that has never seen your code.
The tasks
Do these in order; each maps to a lesson you just finished. Run every command on your own machine and keep the output handy: the rubric checks the real image, not a description of it.
1. Get a working image that runs the model (from Lesson 1)
- Write a
Dockerfilethat copies in the Phase 2 starter’s package + model artifact, installs dependencies, and sets an entry point that scores a record. docker build -t census-scorer .succeeds, anddocker run --rm census-scorer(with a sample record) prints a prediction. The container is the unit of “it runs”, not your laptop.
2. Make the build reproducible (from Lesson 2)
- Pin the Python dependency graph with a lock file installed via
uv sync --frozen, and add a.dockerignoreso the build context is deterministic. Extend the same pin-everything instinct to the base image: name a specific tag (a digest or exact version, never:latest), so the floor of the image does not move under you either. - Re-run
docker buildfrom scratch (--no-cache) and confirm it resolves the same versions. A teammate building it next month gets the same image you do.
3. Make it safe to run anywhere: config + secrets (from Lesson 3)
- Move configuration (model path, threshold, log level) to environment variables read
at runtime through one typed
Settings(BaseSettings)object, the Lesson 3 standard, carried forward from the M1 project’ssettings.py; pass values withdocker run -e. Nothing environment-specific is hardcoded, and no module readsos.environdirectly. - A required value left unset makes the container refuse to start with the field
named, instead of booting on a fallback; prove it with
docker runminus one-e. - Keep secrets out of the image: no credentials in layers, no
.envcopied in. The same image runs on your laptop and a server with only env changes.
4. Make it slim (from Lesson 4)
- Cut the image down: a slim base, a multi-stage build (build deps stay out of the final layer), and an ordered Dockerfile so layer caching actually helps. No multi-GB bloat.
- Record
docker imagesbefore and after. You must be able to justify the final size: what is in the image and why, not just report a number.
5. Run it as a service, not a script (from Lesson 5)
-
Add a long-lived entry point: instead of scoring one record and exiting, run a server so the container stays
Upand answers requests. Building an API properly is M6’s job, so the minimal app is given; copy it into the starter’s emptyserve.py:# serve.py — minimal serving app for this module (M6 builds the real one) import pandas as pd from fastapi import FastAPI from census_pipeline.artifact import TrainedModel model = TrainedModel.load("model/model.joblib") app = FastAPI() @app.get("/health") def health() -> dict: return {"status": "ok"} @app.post("/predict") def predict(record: dict) -> dict: proba = model.predict_proba(pd.DataFrame([record])).iloc[0] return {"probability": float(proba)}The container lives as long as its main process, so the entry point must be a blocking server, not a command that returns.
-
Publish the port with
docker run -p 8000:8000and bind the server to0.0.0.0(not127.0.0.1). Confirm a hostcurl localhost:8000/predictscores a record while the container keeps running. A loopback bind is the canonical “it is up but I cannot reach it” bug: prove yours is not that. -
Mount any run-time state (a directory of prediction outputs, or a swappable model) as a volume with
-v, and confirm it survivesdocker rm. The default model stays baked into the image; what must persist or change without a rebuild is mounted.
6. Compose the stack with a real dependency (from Lesson 6)
-
Replace the pile of
docker runflags with a declarativecompose.yamlthat brings up two services on one command: your scorer and a Postgres container. The scorer reads input records from Postgres (or writes its predictions back to a table; pick one), so it has a genuine dependency, not a sidecar that does nothing. The Python-side database code is not this module’s subject (SQL and DB clients arrive later in the track), so it is given; the work the rubric checks is the compose wiring, not the query:# db.py — given: the compose wiring is the work, not the SQL import os import psycopg2 conn = psycopg2.connect(os.environ["DATABASE_URL"]) # postgresql://user:pw@postgres:5432/db def write_prediction(record_id: str, probability: float) -> None: with conn, conn.cursor() as cur: cur.execute( "CREATE TABLE IF NOT EXISTS predictions (record_id TEXT, probability REAL)" ) cur.execute("INSERT INTO predictions VALUES (%s, %s)", (record_id, probability)) -
Have the scorer reach Postgres by service name (
postgres:5432), notlocalhost. Inside the Compose network,localhostis the scorer’s own container; the database is a separate service with its own DNS name. -
Gate startup on readiness, not start order: give Postgres a
healthcheck(pg_isready) and declaredepends_on:withcondition: service_healthy, so the scorer does not fire its first query before the database can answer. A baredepends_onwaits for the container to start, not for the service inside it to be ready. -
docker compose upbrings the whole stack up on one command;docker compose downtears it down. Confirm the scorer reads/writes the database end to end.
Hints
- Build for a clean machine from day one: a stray local file the container quietly depends
on is invisible until someone else (or CI) builds it.
.dockerignoreplus--no-cacheis how you flush those out before a reviewer does. - Layer order is leverage: copy the lock file and install deps before copying your source, so a code change does not re-run the whole dependency install.
- “Slim” is a chain of small wins: a slim base image, a multi-stage build, and not copying build tools into the final stage usually account for most of the savings.
- If
curl localhost:8000refuses whiledocker psshows the containerUpand the port mapped, the bind address is almost always the culprit: a server bound to127.0.0.1answers only its own loopback, not the forwarded host traffic. Bind0.0.0.0. - If the scorer crashes on its first query because Postgres is not ready, you have a start
order that is not a readiness gate. A plain
depends_ononly waits for the database container to start;condition: service_healthyplus apg_isreadyhealthcheck waits for it to actually accept connections.
Rubric — your project is done when
This is the standard the module holds you to (each bar maps to the lesson that taught it):
- Builds from scratch on a clean machine —
docker build --no-cachesucceeds with no dependency on local state; onedocker runscores a record. (Lessons 1, 2) - Scores a record via one command —
docker runwith a sample record returns a prediction, with no host setup beyond Docker. (Lessons 1, 3) - Dependencies pinned — a lock file installed with
uv sync --frozenfreezes the Python graph, and the same pin-everything instinct fixes the base image to a specific tag/digest; no:latest. (Lesson 2) - Config and secrets externalized — configuration comes from environment variables read
through one typed
Settings(BaseSettings)object, a missing required value refuses to start naming the field, and no secrets are baked into any layer. (Lesson 3) - Image size justified — slim base + multi-stage build, no multi-GB bloat, and you can account for what is in the final image. (Lesson 4)
- Runs as a reachable service — the container runs a long-lived server, stays
Up, and a hostcurl localhost:8000/predictscores a record; the server binds0.0.0.0and run-time state lives on a volume that survivesdocker rm. (Lesson 5) - Composes a multi-service stack — one
docker compose upbrings the scorer and a Postgres dependency up together; the scorer reaches the database by service name, and aservice_healthygate holds startup until Postgres can answer. (Lesson 6) - A README that runs it — the build, run, and compose commands documented so a stranger can execute every rubric line. (all lessons)
Audit your own image before you submit: for each rubric line, run the command that proves it, and if one fails, name which lesson’s failure mode you reintroduced.
Submit
Use the branch workflow from the M1 git lesson, not commits straight to main. Branch
off main (git checkout -b m5-docker-image), build this module’s piece there, and open a
pull request to merge it back once it meets the rubric. main stays the last-good version
of the product you carry forward, so a half-finished module never breaks what later
modules build on.
When your repo meets every rubric line, merge your branch to main, push it to GitHub,
and submit the repository URL here. (Submission coming soon.)
Coming soon
This lesson is not published yet. Join the waitlist to hear when it ships.
Coming soon