Safeguard
Software Supply Chain•

How do you containerize a Python app in Docker the right way?

Containerize a Python app with a multi-stage build, a pinned base image, hashed requirements, a .dockerignore file and a non-root user. Example inside.

Safeguard Security Team
4 min read

To containerize a Python app well, build in one stage and run in a slimmer one, pin the base image, install only hashed and pinned requirements, keep junk out with a .dockerignore file, and run as a non-root user. Each habit shrinks the image or closes a way for tampered code to reach production.

What does a sound Dockerfile look like?

Here is a constructed example for a small web service with a requirements file that already carries hashes. The base image tag is a placeholder you would replace with your own pinned version and digest.

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --require-hashes --only-binary :all: --target /deps -r requirements.txt

FROM python:3.12-slim
RUN groupadd -r app && useradd --no-log-init -r -g app app
WORKDIR /app
COPY --from=build /deps /deps
COPY src/ ./src/
ENV PYTHONPATH=/deps
USER app
CMD ["python", "-m", "src.main"]

The rest of this post explains why each choice is there.

Why use a multi-stage build?

Docker's build guidance says multi-stage builds let you separate building the image from the final output, so the result holds only the files needed to run. Compilers and build tools stay in the first stage. Docker also advises using one image for building and testing and a typically slimmer one for production, since a small image with minimal dependencies can considerably lower the attack surface.

How should you pin the base image?

Tags move. Docker explains that a tag such as a minor version resolves to the latest patch, so two builds months apart can differ. That helps with updates and hurts with reproducibility and audit trails. Pinning to a digest guarantees the same image every time, even if a publisher replaces the tag.

The trade-off is that a pinned digest does not update itself. Pair it with regular rebuilds. Docker recommends rebuilding often because images are snapshots, and notes that the pull flag on the build command forces a check for a newer base image instead of using a cached one.

How do you keep Python dependencies honest?

By default, pip performs no check against remote tampering, and installing a source distribution can run arbitrary code. The pip documentation describes two measures. Hash-checking mode uses hashes embedded in the requirements file, and once you hash one requirement it applies to all of them. Every dependency, including transitive ones, must be listed, hashed and pinned with an exact version. Separately, you can refuse source distributions with the binary-only option.

The example Dockerfile uses both. If a package on the index changes underneath you, the build fails instead of shipping the change.

What belongs in .dockerignore?

Anything the build does not need: version control folders, local virtual environments, test data, notes, and files that hold secrets. Docker supports gitignore-style patterns. This keeps the build context small and stops a stray credentials file from landing in a layer.

Why run as a non-root user?

Docker's guidance says that if a service can run without privileges, use USER to switch to a non-root account, creating the user and group in the Dockerfile first. It also suggests considering an explicit UID and GID because automatic assignment can vary. A compromised app then holds fewer rights inside the container.

How to check or apply this

  1. Split the Dockerfile into a build stage and a runtime stage.
  2. Choose a minimal base image from a source you trust, then pin the tag and digest.
  3. Generate a requirements file with hashes for every dependency and install with hash checking on.
  4. Add a .dockerignore file and review what the build context contains.
  5. Create a non-root user and switch to it with USER.
  6. Rebuild on a schedule with fresh base images and rescan the result.
  7. Keep one concern per container.

Scanning the dependency list is the step teams skip. Safeguard's free in-browser scan at gold.safeguard.sh/scan checks up to 100 dependencies, and the Safeguard CLI can run the same inventory in CI.

Quick check

Does a slim base image make my container secure?

It reduces the attack surface by removing packages, but it does not fix vulnerable dependencies or a root user. Treat it as one layer of several.

Why not just use the latest tag?

The tag changes over time, so builds are not repeatable and you lose an audit trail of what you ran. Pin a version and digest, then rebuild deliberately.

Sources

Last reviewed: 18 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.