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 buildWORKDIR /appENV 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 runtimeWORKDIR /appENV VIRTUAL_ENV=/opt/venv \ PATH="/opt/venv/bin:$PATH" \ PYTHONUNBUFFERED=1COPY --from=build /opt/venv /opt/venvCOPY --from=build /app /appUSER 1001EXPOSE 8000CMD ["python", "-m", "app"]# Build stage: uv resolves and installs into a project venv, fast.FROM ghcr.io/quenchworks/images/uv:0.11 AS buildWORKDIR /appENV 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 runtimeWORKDIR /appENV PATH="/opt/venv/bin:$PATH" \ PYTHONUNBUFFERED=1COPY --from=build /opt/venv /opt/venvCOPY --from=build /app /appUSER 1001EXPOSE 8000CMD ["python", "-m", "app"]# Build stage: Poetry installs into a venv at a known path.FROM ghcr.io/quenchworks/images/poetry:2 AS buildWORKDIR /appENV 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 runtimeWORKDIR /appENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1COPY --from=build /app/.venv /app/.venvCOPY --from=build /app /appUSER 1001EXPOSE 8000CMD ["python", "-m", "app"]What each stage does
- 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.
- Runtime. Start fresh from
python:3.13, copy the venv and the app in, put the venv on thePATH, drop touid 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.