Packaging into a Container
A slim base, dependencies in their own cached layer, a non-root user, and an exec-form CMD that actually receives SIGTERM.
CMD ["python", "-m", "src.server"]The image is the unit you deploy, so its layer order decides how long a one-line fix takes to ship, and its CMD form decides whether your process ever sees a shutdown signal.
Order layers by how often they change
Docker reuses a cached layer until that layer's inputs change. Copy the lockfile, install, and only then copy the source: a code edit now reuses the install layer. Copy the source first and every edit reinstalls every dependency from the network.
The base image
python:3.14-slim is Debian with the build toolchain stripped out — small, and still glibc, so published manylinux wheels install as prebuilt binaries. The Alpine images use musl, so no manylinux wheel matches: a dependency that also publishes a musllinux wheel (PEP 656, which large projects such as NumPy and cryptography do) still installs as a prebuilt binary, but anything with a C extension that does not will compile from source on every build. Set PYTHONDONTWRITEBYTECODE=1 and PYTHONUNBUFFERED=1 so you are not shipping .pyc files or losing the last log lines to a buffer.
Not root
Create a user in a RUN step and switch with USER before the CMD. Without it the process runs as uid 0 inside the container, and any writable mount is writable by root.
Exec form and shell form
CMD python -m src.server is shorthand for CMD ["/bin/sh", "-c", "python -m src.server"] — the same thing, and that is the problem. sh becomes PID 1, receives the SIGTERM, and does not forward it. Python never learns it is being replaced, so the runtime sends SIGKILL when the grace period expires: 10 seconds for docker stop, 30 by default in Kubernetes. Write the JSON array form and your process is PID 1.
Example
When to use it
- Cut rebuild time from minutes to seconds by keeping the pip layer out of the code-edit path.
- Satisfy a cluster policy that rejects any pod running as root.
- Make a rolling deploy finish in-flight requests instead of ending in SIGKILL.
- Ship an image without a compiler in it after a dependency needed one to build.
More examples
The Dockerfile these rules produce
Reading top to bottom, each instruction changes less often than the one below it. The numeric USER matters on Kubernetes clusters whose policy requires runAsNonRoot, which checks the uid and cannot resolve a username.
FROM python:3.14-slim
# Never write .pyc into a read-only layer; never buffer logs the runtime is collecting.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Dependencies first, in their own layer. This layer is rebuilt only when
# requirements.txt changes, which is the whole point.
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes -r requirements.txt
# Source last: the common edit invalidates only what follows.
COPY src/ ./src/
# A real user, created before we drop to it.
RUN useradd --create-home --uid 10001 app
USER 10001
EXPOSE 8080
# Exec form: python is PID 1 and receives SIGTERM directly.
CMD ["python", "-m", "src.server"]Multi-stage: build wheels once, ship a thin runtime
Use this when a dependency has no wheel for your platform: the compiler lives in a stage that is discarded, so the shipped image stays small and has no toolchain for an attacker to use. The --no-index flag also makes the final install deterministic and offline.
# Stage 1 has the compilers. Nothing from here ships unless we copy it.
FROM python:3.14-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Build every dependency to a wheel, including the ones with C extensions.
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Stage 2 is the image you deploy: no gcc, no apt lists, no build artefacts.
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /wheels /wheels
# --no-index: install only what stage 1 produced, so this step needs no network
# and cannot resolve a different version than the one that was built.
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
&& rm -rf /wheels
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app
USER 10001
CMD ["python", "-m", "src.server"].dockerignore, because COPY . . copies your .git
Without this file, COPY . . ships your full git history, your local virtualenv and any .env you were testing with - megabytes of context uploaded on every build, and a secret baked into a layer that persists even if a later instruction deletes the file. Excluding .venv also prevents a host virtualenv from shadowing the one in the image.
# .dockerignore - read by the build client, which strips these paths
# out of the context before it is sent to the builder
.git
.github
.venv
__pycache__/
*.pyc
.pytest_cache
.mypy_cache
.env
*.sqlite3
tests/
Dockerfile
.dockerignore
Discussion