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
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.
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.
-
Initialize the locked environment:
Using
uv, we generate arequirements.txtthat is fully pinned.
# Install uv first
pip install uv
# Generate the lock file
uv pip compile requirements.in -o requirements.txt
Examine the output:
Open the generatedrequirements.txt. You will notice it includes every sub-dependency ofpandasandrequests, each pinned to an exact version with a corresponding hash.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:
-
Determinism: If you run
docker buildtoday and again in six months, the layer containing your dependencies should remain identical because therequirements.txtfile has not changed its hash-verified contents. - 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
useraddcommand 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-onlyflag indocker runensures 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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.