Project — Packaging & Reproducibility

Project Build this yourself, to the spec and rubric below — the module's standard. You build it on your own dataset, alongside the lessons.

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
Project Build this yourself, to the spec and rubric below — the module's standard. You build it on your own dataset, alongside the lessons.

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

  1. Fork the starterdutchengineer-org/phase2-starter, clone it, run python train.py to produce the model artifact
  2. Work the tasks below in order — each maps to a lesson you just finished
  3. 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 Dockerfile that 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, and docker 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 .dockerignore so 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 build from 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’s settings.py; pass values with docker run -e. Nothing environment-specific is hardcoded, and no module reads os.environ directly.
  • 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 run minus one -e.
  • Keep secrets out of the image: no credentials in layers, no .env copied 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 images before 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 Up and answers requests. Building an API properly is M6’s job, so the minimal app is given; copy it into the starter’s empty serve.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:8000 and bind the server to 0.0.0.0 (not 127.0.0.1). Confirm a host curl localhost:8000/predict scores 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 survives docker 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 run flags with a declarative compose.yaml that 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), not localhost. Inside the Compose network, localhost is 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 declare depends_on: with condition: service_healthy, so the scorer does not fire its first query before the database can answer. A bare depends_on waits for the container to start, not for the service inside it to be ready.

  • docker compose up brings the whole stack up on one command; docker compose down tears 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. .dockerignore plus --no-cache is 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:8000 refuses while docker ps shows the container Up and the port mapped, the bind address is almost always the culprit: a server bound to 127.0.0.1 answers only its own loopback, not the forwarded host traffic. Bind 0.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_on only waits for the database container to start; condition: service_healthy plus a pg_isready healthcheck 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 machinedocker build --no-cache succeeds with no dependency on local state; one docker run scores a record. (Lessons 1, 2)
  • Scores a record via one commanddocker run with a sample record returns a prediction, with no host setup beyond Docker. (Lessons 1, 3)
  • Dependencies pinned — a lock file installed with uv sync --frozen freezes 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 host curl localhost:8000/predict scores a record; the server binds 0.0.0.0 and run-time state lives on a volume that survives docker rm. (Lesson 5)
  • Composes a multi-service stack — one docker compose up brings the scorer and a Postgres dependency up together; the scorer reaches the database by service name, and a service_healthy gate 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