Quenchworks

Build a Python app

Python’s wheels and build tooling make a clean split worthwhile. Compile and install everything into a self-contained virtualenv in the build stage, then copy that one directory onto a slim python base for the runtime. The compilers and headers some packages need at install time never reach the image you deploy.

These build on ghcr.io/quenchworks/images/python:3.13. For dependency resolution there is ghcr.io/quenchworks/images/uv (a fast installer and resolver) and ghcr.io/quenchworks/images/poetry.

The multi-stage Dockerfile

# Build stage: build a venv with everything installed into it.
FROM ghcr.io/quenchworks/images/python:3.13 AS build
WORKDIR /app
ENV VIRTUAL_ENV=/opt/venv \
PATH="/opt/venv/bin:$PATH"
RUN ["python", "-m", "venv", "/opt/venv"]
COPY requirements.txt ./
RUN ["pip", "install", "--no-cache-dir", "-r", "requirements.txt"]
COPY . .
# Runtime stage: copy the venv onto a clean python base, run nonroot.
FROM ghcr.io/quenchworks/images/python:3.13 AS runtime
WORKDIR /app
ENV VIRTUAL_ENV=/opt/venv \
PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1
COPY --from=build /opt/venv /opt/venv
COPY --from=build /app /app
USER 1001
EXPOSE 8000
CMD ["python", "-m", "app"]
# Build stage: uv resolves and installs into a project venv, fast.
FROM ghcr.io/quenchworks/images/uv:0.11 AS build
WORKDIR /app
ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_COMPILE_BYTECODE=1
# Install dependencies first (cached) from the lockfile, then the project.
COPY pyproject.toml uv.lock ./
RUN ["uv", "sync", "--frozen", "--no-install-project", "--no-dev"]
COPY . .
RUN ["uv", "sync", "--frozen", "--no-dev"]
FROM ghcr.io/quenchworks/images/python:3.13 AS runtime
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1
COPY --from=build /opt/venv /opt/venv
COPY --from=build /app /app
USER 1001
EXPOSE 8000
CMD ["python", "-m", "app"]
# Build stage: Poetry installs into a venv at a known path.
FROM ghcr.io/quenchworks/images/poetry:2 AS build
WORKDIR /app
ENV POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1
COPY pyproject.toml poetry.lock ./
RUN ["poetry", "install", "--only", "main", "--no-root"]
COPY . .
RUN ["poetry", "install", "--only", "main"]
FROM ghcr.io/quenchworks/images/python:3.13 AS runtime
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
COPY --from=build /app/.venv /app/.venv
COPY --from=build /app /app
USER 1001
EXPOSE 8000
CMD ["python", "-m", "app"]

What each stage does

  1. Build. Create a virtualenv at a fixed path and install every dependency into it. This stage carries the resolver, any build backends, and the wheels’ build dependencies. Installing dependencies before copying the rest of the source keeps that layer cached when only your code changes.
  2. Runtime. Start fresh from python:3.13, copy the venv and the app in, put the venv on the PATH, drop to uid 1001, and start. Nothing from the build toolchain ships.

Next

  • Serving with a WSGI/ASGI server? Put it in the venv (gunicorn, uvicorn) and call it directly in exec form: CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"].
  • Pin by digest so each build runs exactly the base that was scanned and signed.