# syntax=docker/dockerfile:1 # # THE backend image — one Dockerfile for all three consumers, so the build surface # (deps, Chromium, runtime) can never drift between them: # • Production / Hugging Face Space — built with the DEFAULT args (SOURCE_MODE=clone). # This is the CANONICAL production target, mirrored into the public HF Space repo # (huggingface.co/spaces/lvhoang/teaching-bot), whose only tracked files are this # Dockerfile plus DEPLOY_REF / DEPLOY_SHA — never application source. # • CI build/import smoke and local docker-compose — built with SOURCE_MODE=local, # which COPYs source from the build context instead of cloning. The clone stage is # then outside the build graph, so no GH_TOKEN / network / pushed ref is needed. # See README.md in this directory for the deploy model and the one-time Space sync. # # Leak invariant: source lives only in the PRIVATE GitHub repo. The production build # clones it at DEPLOY_REF using a build-time secret (GH_TOKEN) in the `src-clone` # stage; only the built venv + the shipped packages are copied into the final image. # Nothing is committed to the public Space, and the token + .git never exist in a # final-image layer. The *endpoint* is public (the frontend calls it); the *source* is # not. GH_TOKEN MUST be a fine-grained, read-only, single-repo, short-lived PAT so a # leaked build log has minimal blast radius. The clone reads $(cat /run/secrets/...) # so BuildKit never prints the expanded token. The `src-local` stage's COPYs are inert # in the Space build (that stage is never built when SOURCE_MODE=clone), so they can # never sweep local files into the public-endpoint image. # DEFAULT = clone, so the HF Space build (which passes no build args) produces prod. ARG SOURCE_MODE=clone ############################################################################## # Source acquisition — two interchangeable stages, selected by SOURCE_MODE. # # Each assembles the shipped layout under /app: the explicit package list is # # deliberate (a wholesale `backend/` copy could sweep a stray .env / .git in).# # backend/tests/test_hf_space_build.py guards that this list stays in sync # # with the wheel packages in pyproject.toml. start.sh comes from the SOURCE # # (infra/hf-space/start.sh), never the Space repo, so it can't drift from the # # app it launches. # ############################################################################## FROM python:3.12-slim AS src-clone RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* COPY DEPLOY_REF /tmp/DEPLOY_REF RUN --mount=type=secret,id=GH_TOKEN \ REF="$(tr -d '\r\n' < /tmp/DEPLOY_REF)" \ && git clone --depth 1 --branch "$REF" \ "https://x-access-token:$(cat /run/secrets/GH_TOKEN)@github.com/henryle97/teaching-bot" /tmp/src \ && mkdir -p /app \ && cp -r /tmp/src/backend/app /tmp/src/backend/engine /tmp/src/backend/worker \ /tmp/src/backend/pyproject.toml /tmp/src/backend/uv.lock /app/ \ && cp /tmp/src/infra/hf-space/start.sh /app/start.sh \ && rm -rf /tmp/src FROM python:3.12-slim AS src-local COPY backend/app /app/app COPY backend/engine /app/engine COPY backend/worker /app/worker COPY backend/pyproject.toml backend/uv.lock /app/ COPY infra/hf-space/start.sh /app/start.sh # Pick the source stage. `src-${SOURCE_MODE}` resolves to src-clone or src-local; # only the selected one is in the build graph (so local builds skip the clone/secret). FROM src-${SOURCE_MODE} AS source ############################################################################## # Builder — build the venv. Discarded afterward, so its uv/git/curl never # # reach the published image. No build-essential: every dependency resolves to # # a prebuilt manylinux wheel, so `uv sync --frozen` needs no C toolchain. # ############################################################################## FROM python:3.12-slim AS builder RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* ENV UV_LINK_MODE=copy \ UV_PROJECT_ENVIRONMENT=/app/.venv \ PATH=/root/.local/bin:$PATH RUN curl -LsSf https://astral.sh/uv/install.sh | sh WORKDIR /app COPY --from=source /app /app # --no-dev: the image never runs pytest/ruff/respx, so the dev group is excluded. RUN uv sync --frozen --no-install-project --no-dev ############################################################################## # Runtime — slim base + Chromium + the prebuilt venv. No compilers, no uv, # # no git, no clone temp; runs as the unprivileged `user`. # ############################################################################## FROM python:3.12-slim RUN useradd -m -u 1000 user ENV HOME=/home/user \ PATH=/app/.venv/bin:$PATH \ PYTHONUNBUFFERED=1 \ # Shared, world-readable Chromium location so the unprivileged runtime user finds # the browser installed (as root) at build time. Used by engine/thumbnail.py. PLAYWRIGHT_BROWSERS_PATH=/ms-playwright # Headless Chromium + its system libraries for first-slide thumbnails # (engine/thumbnail.py, worker-only). Deliberately the FIRST runtime layer, above the # COPY of the app: the browser binary and apt libs never change between deploys, so # keying this layer only on the pinned PLAYWRIGHT_VERSION lets every ordinary deploy # reuse it from cache instead of re-downloading Chromium each build. We pip-install the # playwright CLI just to fetch the browser into the shared PLAYWRIGHT_BROWSERS_PATH + # the system libs, then drop the CLI; the copied venv ships the matching playwright, # which finds these prebaked binaries at runtime. `--only-shell` installs only # chrome-headless-shell (~175MB smaller than the full browser) — we only ever render # headless (engine/thumbnail.py pins channel="chromium-headless-shell"). # PLAYWRIGHT_VERSION must equal the playwright version in backend/uv.lock — guarded by # backend/tests/test_hf_space_build.py. Best-effort by design: if a launch ever fails # at runtime, render_thumbnail returns None and the dashboard falls back to ThemeArt — # and the thumbnails_enabled env flag can disable rendering entirely without a rebuild. ARG PLAYWRIGHT_VERSION=1.60.0 RUN pip install --no-cache-dir "playwright==${PLAYWRIGHT_VERSION}" \ && apt-get update \ && playwright install-deps chromium \ && playwright install --only-shell chromium \ && chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \ && pip uninstall -y playwright \ && rm -rf /var/lib/apt/lists/* # The venv is built at the same /app/.venv path it runs from, so its interpreter # shebangs and pyvenv.cfg stay valid across the stage boundary. COPY --from=builder --chown=user:user /app /app WORKDIR /app USER user EXPOSE 7860 CMD ["bash", "start.sh"]