Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Why Your Docker Builds Aren't Reproducible (And How to Fix Them)

The myth that pip install -r requirements.txt creates a reproducible environment is the single most common cause of "it works on my machine" bugs in data engineering pipelines. Why this matters: In regulated data envir

Why Your Docker Builds Aren't Reproducible (And How to Fix Them)

The myth that pip install -r requirements.txt creates a reproducible environment is the single most common cause of "it works on my machine" bugs in data engineering pipelines.

Why this matters: In regulated data environments, non-deterministic builds are a compliance risk. If you cannot guarantee exactly which sub-dependency versions were installed in your production container, you cannot guarantee the integrity of your data processing logic.

The problem with loose requirements

When you define dependencies in a requirements.txt file using loose versioning (e.g., pandas>=2.0.0), pip resolves the environment at build time. If you rebuild that same image two weeks later, pip may fetch newer versions of sub-dependencies, resulting in a different runtime environment than the one you tested. To achieve true reproducibility, you need a "lock file"—a static snapshot of the entire dependency graph, including hashes for security.

a computer screen with a bunch of text on it
Photo by FONG on Unsplash

Setup

We will compare a standard, non-deterministic requirements.txt approach with a locked environment using uv (a modern, high-performance Python package manager).

Ensure you have Docker installed. We will use python:3.11-slim as our base image.

Create a directory with the following files:

requirements.in

pandas>=2.0.0
requests

Dockerfile.loose

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

The experiment

In this experiment, we will generate a lock file and compare the build behavior.

  1. Initialize the locked environment: Using uv, we generate a requirements.txt that is fully pinned.
   # Install uv first
   pip install uv
   # Generate the lock file
   uv pip compile requirements.in -o requirements.txt
  1. Examine the output:
    Open the generated requirements.txt. You will notice it includes every sub-dependency of pandas and requests, each pinned to an exact version with a corresponding hash.

  2. Build the hardened image:
    We use a multi-stage Dockerfile to ensure our final image is minimal and secure.

Dockerfile

# Stage 1: Build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc python3-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Final runtime
FROM python:3.11-slim
# Create a non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# Copy only the installed packages from builder
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
USER appuser
# Add a healthcheck
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import pandas; print('ok')" || exit 1

What to measure

You are looking for two specific outcomes:

  1. Determinism: If you run docker build today and again in six months, the layer containing your dependencies should remain identical because the requirements.txt file has not changed its hash-verified contents.
  2. Security: Because you are using fixed versions and hashes, you have eliminated the risk of a malicious actor publishing a compromised "new" version of a sub-dependency that your loose requirement would have automatically pulled in.

Reproduce it

Run the following commands to confirm the build process:

# 1. Prepare requirements
pip install uv
uv pip compile requirements.in -o requirements.txt

# 2. Build the image
docker build -t my-hardened-app:latest .

# 3. Verify the image layers
docker history my-hardened-app:latest

# 4. Run the container as non-root
docker run --rm --read-only --tmpfs /tmp --tmpfs /home/appuser/.local/lib/python3.11/site-packages/my_app my-hardened-app:latest

Why this is the standard

By using uv pip compile (or pip-compile from pip-tools), you move the "resolution" step outside of your Docker build. The Dockerfile now only performs "installation."

If you do not lock your files, you are effectively letting the pip resolver perform a network call to PyPI to determine the latest available versions every time you build. This introduces a non-deterministic variable into your pipeline. In regulated industries, where "reproducibility" is often a requirement for audit trails, this is unacceptable.

Security hardening notes

  • Multi-stage builds: By separating the build environment (which needs compilers like gcc) from the runtime environment, you drastically reduce the attack surface of your final image.
  • Non-root users: The useradd command creates a dedicated user. Running as root inside a container is a vulnerability that should be avoided by default, especially in shared Kubernetes clusters.
  • Read-only filesystems: The --read-only flag in docker run ensures that your application cannot modify its own source code or install new packages at runtime, which is a critical defense-in-depth practice.

By treating your dependencies as a static, locked artifact, you align your Python environment management with the same rigor you apply to your infrastructure-as-code or database migrations. If the environment is not pinned, it is not production-ready.

Cover photo by Growtika on Unsplash.

This article was drafted with AI assistance and is intended for technical review and fact-checking before publication.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.