ADR-0013: Deploy the UI as a warm, offline Docker image¶
Date: 2026-06-20 Status: Accepted
Context¶
Phases 0–8 are complete and the Phase 8 Streamlit UI (ADR-0012) runs locally with
streamlit run app/streamlit_app.py. To make it a clickable portfolio piece it
needs to run on a fresh host. Two facts from the codebase shape the packaging:
artifacts/is gitignored and the catalog is not in git. A fresh clone has no fitted vectors, no FAISS indexes, no Map projection, and nodata/processed/courses.parquet. Re-deriving them on a cold host means encoding ~11k SBERT vectors and a t-SNE projection on first load (~30 s, per HANDOFF) — and re-running the whole data pipeline.- What each UI path needs at runtime differs.
recommend_similar, Compare, and the Map load straight from the bakedartifacts/(no model weights —fitcalls_load, never_load_model). Only a free-text query encodes the query string, which needs the MiniLM weights. Sentence-Transformers pulls those from Hugging Face on first use — a network dependency at runtime unless pre-pulled. torch==2.12.0's default Linux wheel is the CUDA build (~2 GB+). A CPU-only deploy that pulls it blindly produces a needlessly huge image.
Decision¶
Ship a single CPU-only Docker image that starts warm and needs no network at
runtime, via a Dockerfile + .dockerignore + .streamlit/config.toml:
- Bake the warm state into the image.
COPYthe processed parquet,results/, and only theartifacts/that back the six UI rungs + the Map. The.dockerignoredrops the raw CSV, the regenerable embedding/LLM caches (embcache,llmcache,llm_tags), and the rungs the UI never fits (graph, NMF, LDA, the extra TF-IDF/BM25/metadata variants) — ~148 MB of artifacts in, ~100 MB excluded. - Pre-pull the default MiniLM weights at build time (one
RUNthat constructsSentenceTransformer('all-MiniLM-L6-v2')into a bakedHF_HOME), so free-text queries work fully offline. The MPNet rung is the deliberate exception: its weights download on first use (graceful — slower, needs network), not baked, to keep the image lean. - Force the CPU torch wheel. Install
torch==2.12.0fromhttps://download.pytorch.org/whl/cpubeforepip install -e ".[ui,semantic]", so the package install sees torch satisfied and never pulls the CUDA default. - Honor
$PORT, run headless as non-root.config.tomlsets headless / bind0.0.0.0/ usage-stats off; theCMDbinds--server.port=${PORT:-8501}so PaaS hosts (Cloud Run, HF Spaces) that inject$PORTwork unchanged. A non-rootappuserowns the app tree and HF cache; a/_stcore/healthHEALTHCHECK probes readiness.
Alternatives considered¶
- Cold image (no baked artifacts), compute on first load. Rejected: a ~30 s cold-start on every fresh container (and a full data-pipeline run to even produce the parquet) is a poor portfolio first impression, and the pipeline's raw CSV isn't in the image. Warm trades image size for instant, offline startup.
- Bake all artifacts (the full 250 MB sweep). Rejected: the UI fits only six
rungs; shipping NMF/LDA/graph/duplicate variants and the regenerable caches bloats
the image for paths the UI never exercises. The full sweep stays a
run_evalconcern (ADR-0012's curated-subset decision carries over). - Pre-pull every model (incl. MPNet, cross-encoder). Rejected: MPNet's weights roughly double the model footprint for a non-default rung. Baking only the default keeps the common path offline and the image small; MPNet degrades to a one-time download, consistent with the repo's graceful-degradation rule.
- Default the base PyPI torch wheel. Rejected: it's the CUDA build on Linux — gigabytes of unused CUDA libraries for a CPU deploy.
- A Streamlit Community Cloud / HF Spaces config instead of Docker. Not chosen as
the primary: Docker is the portable, host-agnostic artifact and the
$PORT+ headless config already make it Spaces/Cloud-Run-friendly. A SpacesDockerfileSpace can reuse this image as-is.
Consequences¶
Positive. docker build -t course-recommender-lab . && docker run -p 8501:8501
course-recommender-lab yields a UI that starts warm and offline: course-to-course, Compare,
Map, the Leaderboard, and free-text queries all work with no network and no
first-load encode. CPU-only and $PORT-aware, so it runs on a laptop or a PaaS host
unchanged. No application code changed — this is pure packaging over ADR-0012.
Honest caveats. (1) The build is not hermetic: it COPYs data/processed/
and artifacts/, which are gitignored, so the build host must have a warm repo (a
normal local state after running the pipeline + eval). A truly fresh clone must
regenerate those first — documented in the RUNBOOK. (2) The MPNet rung and the
Phase 7 why-line still need network/Ollama on demand (unchanged degradation). (3)
The image pins torch==2.12.0 from the CPU index; a version the CPU index lacks
would fail the build loudly (build-time, not runtime).
Neutral. No change to the library, eval, leaderboard, or tests; absent Docker, everything runs exactly as before. Builds on the UI in ADR-0012.
Implementation notes¶
Dockerfile (python:3.11-slim, libgomp1, CPU torch, .[ui,semantic], MiniLM
pre-pull, non-root appuser, $PORT-aware CMD, /_stcore/health HEALTHCHECK),
.dockerignore (warm-subset allowlist by exclusion), .streamlit/config.toml
(headless / bind / usage-stats / no file-watcher). RUNBOOK §"Deploy" documents the
warm-repo prerequisite and the build/run commands. Implements the HANDOFF deploy
task.