Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]