From d1684ef5ce601884e022e2305ceba0cff9b5ac4d Mon Sep 17 00:00:00 2001 From: "const.koutsakis@aurecongroup.com" Date: Sun, 26 Apr 2026 20:16:15 +1000 Subject: [PATCH] chore: Dockerfile (multi-stage, Python 3.14, non-root, healthcheck) (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Teller's multi-stage Dockerfile, bump base to python:3.14-slim. Builder materialises the venv via uv sync --frozen --no-dev; runtime copies only the venv + src/ onto a fresh slim base, runs as non-root user `app`. HEALTHCHECK hits /api/v1/health (template's API prefix). Drop the data/ COPY — template has no data dir. UV_PYTHON_DOWNLOADS=never + UV_PYTHON_PREFERENCE=only-system pin to the system Python so pyvenv.cfg symlinks survive the stage handover. Closes #6 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5135d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# Multi-stage build: keep `uv` and the build-time toolchain out of the +# runtime image. The builder stage materialises `.venv` from the locked +# dep set; the runtime stage copies only the venv + source onto a fresh +# `python:3.14-slim` base. See SECURITY.md "Container Hardening" for the +# threat model. + +##################################################################### +# Builder — has uv, pip, and whatever transitive build deps `uv sync` +# touches. Nothing from this stage ships. +##################################################################### +FROM python:3.14-slim AS builder + +# `--python-preference only-system` + `UV_PYTHON_DOWNLOADS=never` forbid +# uv from downloading its own Python interpreter — we want the venv +# linked against the same /usr/local Python the runtime stage carries, +# so the `pyvenv.cfg` symlinks resolve after the COPY --from=builder. +ENV UV_PYTHON_DOWNLOADS=never \ + UV_PYTHON_PREFERENCE=only-system + +RUN pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +##################################################################### +# Runtime — minimal: Python, the materialised venv, app source, and +# a non-root user. No uv, no pip cache, no build tools. +# +# Must match the builder's base image — the venv's `pyvenv.cfg` and +# interpreter symlinks reference /usr/local/bin/python3.14. Switching +# to e.g. python:3.14-alpine here would leave broken symlinks because +# Alpine puts Python at /usr/bin/python3.14. +##################################################################### +FROM python:3.14-slim AS runtime + +WORKDIR /app + +# Create the non-root user FIRST so the subsequent COPY --chown +# directives can reference it. Doing it this way avoids a separate +# `chown -R app:app /app` walk over the (thousands of files in the) +# venv — ownership is baked into each COPY layer at write time. +RUN groupadd --system app \ + && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app + +# Bring across only the venv (built above) and the application source. +# The builder's pip install + uv binary stay behind in the builder +# layer cache; they never enter the published image. +COPY --from=builder --chown=app:app /app/.venv /app/.venv +COPY --chown=app:app src/ src/ + +USER app + +# Put the venv on PATH so `uvicorn` resolves without `uv run` indirection. +ENV PATH="/app/.venv/bin:${PATH}" + +EXPOSE 8000 + +# Python's stdlib urllib avoids needing curl in the image. urlopen raises +# on non-2xx / timeout / connection-error, exiting Python non-zero and +# marking the container unhealthy. +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=2)" || exit 1 + +# Direct uvicorn invocation — the venv is on PATH, no `uv run` wrapper +# needed (and uv isn't here to call anyway). +CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]